This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 74de33af4b [#12237] fix(iceberg): de-flake IcebergClassLoaderPoolIT by
tolerating concurrent JdbcCatalog view-migration on a shared iceberg_tables
(#12240)
74de33af4b is described below
commit 74de33af4bacc423162802b93f4cc3396fd624df
Author: Qi Yu <[email protected]>
AuthorDate: Wed Jul 29 14:22:33 2026 +0800
[#12237] fix(iceberg): de-flake IcebergClassLoaderPoolIT by tolerating
concurrent JdbcCatalog view-migration on a shared iceberg_tables (#12240)
### What changes were proposed in this pull request?
De-flake `IcebergClassLoaderPoolIT` and harden Iceberg JDBC catalog
initialization when several logical catalogs share one JDBC metadata
database:
- Detect the narrow V1 migration conflict where `JdbcCatalog.initialize`
fails because the `iceberg_type` column already exists.
- Close the failed catalog's partially initialized JDBC connection pool
and `FileIO` before discarding it. Iceberg creates these resources
before the schema migration but does not register them with its
`CloseableGroup` until the migration succeeds.
- Initialize a fresh catalog once after the conflict. The shared schema
has already been migrated, so the retry can complete normally.
- Recognize MySQL, PostgreSQL, and SQLite duplicate-column error
wordings in `isConcurrentViewMigrationConflict`.
- Keep the existing access-denied conversion null-safe and simplify the
authentication branch without changing its behavior.
### Why are the changes needed?
Iceberg JDBC catalogs that use the same backend `uri` share the physical
`iceberg_tables` and `iceberg_namespace_properties` control tables.
Different `catalog-backend-name` values isolate their logical namespaces
and tables through the `catalog_name` column, so this is a valid,
although relatively narrow, deployment topology.
Gravitino defaults `jdbc.schema-version` to `V1`. Iceberg creates
`iceberg_tables` without `iceberg_type`, checks for that column with
JDBC metadata, and then runs a non-idempotent migration:
```sql
ALTER TABLE iceberg_tables ADD COLUMN iceberg_type ...
```
The conflict can occur when:
1. Two JDBC catalog instances point to the same backend `uri`.
2. They initialize concurrently while the shared control table is being
migrated to V1, or a JDBC metadata probe temporarily reports the
already-added column as missing.
3. Both instances attempt the same `ALTER TABLE`; the losing instance
fails with a duplicate-column error such as:
```text
Caused by: java.sql.SQLSyntaxErrorException: Duplicate column name
'iceberg_type'
at
org.apache.iceberg.jdbc.JdbcCatalog.executeV1CatalogUpdate(JdbcCatalog.java:276)
at
org.apache.iceberg.jdbc.JdbcCatalog.updateSchemaIfRequired(JdbcCatalog.java:235)
```
`IcebergClassLoaderPoolIT` deliberately creates multiple catalogs on the
same MySQL JDBC URL because it verifies that catalogs with the same
isolation key share a ClassLoader and that dropping one catalog does not
break its sibling. This supported topology exposed the migration race in
`JDK17-deploy-mysql`.
The duplicate-column error means another initializer has completed the
shared migration. This patch therefore closes the losing catalog's
partial resources and retries initialization once with a fresh catalog.
Other SQL failures are still propagated unchanged.
Fix: #12237
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
- Added classifier tests for MySQL, PostgreSQL, and SQLite
duplicate-column messages, plus unrelated and null-message cases.
- Added a deterministic failed-migration test that simulates the
duplicate `iceberg_type` error and verifies that the JDBC pool,
underlying connection, and `FileIO` are closed.
- `./gradlew :iceberg:iceberg-common:test
:iceberg:iceberg-common:spotlessCheck` — all 64 tests pass.
- `IcebergClassLoaderPoolIT` continues to cover the end-to-end
shared-URI scenario in the deploy/MySQL CI.
---
.../iceberg/common/ClosableJdbcCatalog.java | 28 +++++++-
.../iceberg/common/utils/IcebergCatalogUtil.java | 79 ++++++++++++++++++----
.../iceberg/common/TestClosableJdbcCatalog.java | 54 +++++++++++++++
.../common/utils/TestIcebergCatalogUtil.java | 48 +++++++++++++
4 files changed, 193 insertions(+), 16 deletions(-)
diff --git
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ClosableJdbcCatalog.java
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ClosableJdbcCatalog.java
index 6e2dd5c07e..bffedf2c8c 100644
---
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ClosableJdbcCatalog.java
+++
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ClosableJdbcCatalog.java
@@ -23,6 +23,7 @@ import java.io.Closeable;
import java.util.Map;
import java.util.function.Function;
import javax.annotation.Nullable;
+import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.catalog.hadoop.auth.KerberosClient;
import org.apache.gravitino.iceberg.common.authentication.AuthenticationConfig;
import org.apache.gravitino.iceberg.common.authentication.SupportsKerberos;
@@ -73,7 +74,12 @@ public class ClosableJdbcCatalog extends JdbcCatalog
implements Closeable, Suppo
*/
@Override
public void initialize(String inputName, Map<String, String> properties) {
- super.initialize(inputName, properties);
+ try {
+ super.initialize(inputName, properties);
+ } catch (RuntimeException e) {
+ closePartiallyInitializedResources(e);
+ throw e;
+ }
AuthenticationConfig authenticationConfig = new
AuthenticationConfig(properties);
if (authenticationConfig.isKerberosAuth()) {
@@ -105,4 +111,24 @@ public class ClosableJdbcCatalog extends JdbcCatalog
implements Closeable, Suppo
public <R> R doKerberosOperations(Executable<R> executable) throws Throwable
{
return KerberosCatalogUtils.doKerberosOperations(this.properties(),
kerberosClient, executable);
}
+
+ private void closePartiallyInitializedResources(RuntimeException
initializationException) {
+ // JdbcCatalog creates these resources before updating the JDBC schema,
but registers them with
+ // its CloseableGroup only after the update succeeds. Its close() method
therefore cannot clean
+ // them up when initialization fails during the schema update.
+ closePartiallyInitializedResource("connections", initializationException);
+ closePartiallyInitializedResource("io", initializationException);
+ }
+
+ private void closePartiallyInitializedResource(
+ String fieldName, RuntimeException initializationException) {
+ try {
+ Closeable resource = (Closeable) FieldUtils.readField(this, fieldName,
true);
+ if (resource != null) {
+ resource.close();
+ }
+ } catch (Exception closeException) {
+ initializationException.addSuppressed(closeException);
+ }
+ }
}
diff --git
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
index a5587964e5..466a9de314 100644
---
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
+++
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
@@ -57,6 +57,12 @@ public class IcebergCatalogUtil {
private static final Logger LOG =
LoggerFactory.getLogger(IcebergCatalogUtil.class);
+ /**
+ * Column that Iceberg adds to the {@code iceberg_tables} control table in
its V1 view-support
+ * migration (see {@code JdbcUtil} in iceberg-core).
+ */
+ private static final String ICEBERG_TYPE_COLUMN = "iceberg_type";
+
private static InMemoryCatalog loadMemoryCatalog(IcebergConfig
icebergConfig) {
String icebergCatalogName = icebergConfig.getCatalogBackendName();
InMemoryCatalog memoryCatalog = new
MemoryCatalogWithMetadataLocationSupport();
@@ -125,29 +131,72 @@ public class IcebergCatalogUtil {
HdfsConfiguration hdfsConfiguration = new HdfsConfiguration();
properties.forEach(hdfsConfiguration::set);
AuthenticationConfig authenticationConfig = new
AuthenticationConfig(properties);
+ if (authenticationConfig.isKerberosAuth()) {
+ hdfsConfiguration.set(HADOOP_SECURITY_AUTHORIZATION, "true");
+ hdfsConfiguration.set(HADOOP_SECURITY_AUTHENTICATION, "kerberos");
+ } else if (!authenticationConfig.isSimpleAuth()) {
+ throw new UnsupportedOperationException(
+ "Unsupported authentication method: " +
authenticationConfig.getAuthType());
+ }
+
try {
- if (authenticationConfig.isSimpleAuth()) {
- jdbcCatalog.setConf(hdfsConfiguration);
- jdbcCatalog.initialize(icebergCatalogName, properties);
- } else if (authenticationConfig.isKerberosAuth()) {
- hdfsConfiguration.set(HADOOP_SECURITY_AUTHORIZATION, "true");
- hdfsConfiguration.set(HADOOP_SECURITY_AUTHENTICATION, "kerberos");
- jdbcCatalog.setConf(hdfsConfiguration);
- jdbcCatalog.initialize(icebergCatalogName, properties);
- } else {
- throw new UnsupportedOperationException(
- "Unsupported authentication method: " +
authenticationConfig.getAuthType());
- }
+ jdbcCatalog.setConf(hdfsConfiguration);
+ jdbcCatalog.initialize(icebergCatalogName, properties);
} catch (UncheckedSQLException e) {
- if (e.getCause() instanceof SQLException
- && e.getCause().getMessage().contains("Access denied")) {
+ Throwable cause = e.getCause();
+ if (cause instanceof SQLException
+ && cause.getMessage() != null
+ && cause.getMessage().contains("Access denied")) {
throw new ConnectionFailedException(e, e.getMessage());
}
- throw e;
+ if (!isConcurrentViewMigrationConflict(e)) {
+ throw e;
+ }
+ // Iceberg's V1 view-support migration adds the `iceberg_type` column to
the shared
+ // `iceberg_tables` control table with a non-idempotent `ALTER TABLE ...
ADD COLUMN`. When
+ // several Iceberg JDBC catalogs share the same backend `uri` (hence one
`iceberg_tables`), a
+ // losing racer finds the column already added by another catalog and
fails initialization.
+ // The schema is already at V1, so re-initialize a fresh catalog once:
the column now exists,
+ // Iceberg skips the migration, and initialization completes.
+ LOG.info(
+ "iceberg_type column already added by another Iceberg JDBC catalog
sharing the same "
+ + "backend uri; re-initializing catalog {}",
+ icebergCatalogName);
+ jdbcCatalog =
+ new JdbcCatalogWithMetadataLocationSupport(
+ icebergConfig.get(IcebergConfig.JDBC_INIT_TABLES));
+ jdbcCatalog.setConf(hdfsConfiguration);
+ jdbcCatalog.initialize(icebergCatalogName, properties);
}
return jdbcCatalog;
}
+ /**
+ * Whether an {@link UncheckedSQLException} from {@code
JdbcCatalog.initialize} is the benign
+ * conflict raised when another Iceberg JDBC catalog sharing the same
backend {@code uri} already
+ * ran Iceberg's V1 view-support migration.
+ *
+ * <p>That migration adds the {@value #ICEBERG_TYPE_COLUMN} column to the
shared {@code
+ * iceberg_tables} table with a non-idempotent {@code ALTER TABLE ... ADD
COLUMN}, so a losing
+ * racer fails with a duplicate-column error whose wording differs per
database (MySQL: {@code
+ * Duplicate column name 'iceberg_type'}; PostgreSQL: {@code column
"iceberg_type" ... already
+ * exists}; SQLite: {@code duplicate column name: iceberg_type}).
+ *
+ * @param e the exception thrown by catalog initialization
+ * @return {@code true} if the failure is a duplicate {@value
#ICEBERG_TYPE_COLUMN} column
+ * conflict
+ */
+ @VisibleForTesting
+ static boolean isConcurrentViewMigrationConflict(UncheckedSQLException e) {
+ Throwable cause = e.getCause();
+ if (!(cause instanceof SQLException) || cause.getMessage() == null) {
+ return false;
+ }
+ String message = cause.getMessage().toLowerCase(Locale.ROOT);
+ return message.contains(ICEBERG_TYPE_COLUMN)
+ && (message.contains("duplicate column") || message.contains("already
exists"));
+ }
+
private static Catalog loadRestCatalog(IcebergConfig icebergConfig) {
String icebergCatalogName = icebergConfig.getCatalogBackendName();
RESTCatalog restCatalog = new RESTCatalog();
diff --git
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestClosableJdbcCatalog.java
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestClosableJdbcCatalog.java
index 4e386fa5fa..f142af2a1b 100644
---
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestClosableJdbcCatalog.java
+++
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestClosableJdbcCatalog.java
@@ -20,6 +20,12 @@
package org.apache.gravitino.iceberg.common;
import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLSyntaxErrorException;
+import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
@@ -28,9 +34,13 @@ import
org.apache.gravitino.iceberg.common.authentication.kerberos.KerberosConfi
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hdfs.HdfsConfiguration;
import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.jdbc.JdbcClientPool;
+import org.apache.iceberg.jdbc.UncheckedSQLException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
public class TestClosableJdbcCatalog {
@@ -46,6 +56,35 @@ public class TestClosableJdbcCatalog {
Assertions.assertDoesNotThrow(catalog::close);
}
+ @Test
+ void testFailedSchemaMigrationClosesPartiallyCreatedResources() throws
Exception {
+ FileIO fileIO = Mockito.mock(FileIO.class);
+ Connection connection = Mockito.mock(Connection.class);
+ DatabaseMetaData metadata = Mockito.mock(DatabaseMetaData.class);
+ ResultSet columns = Mockito.mock(ResultSet.class);
+ PreparedStatement alterTable = Mockito.mock(PreparedStatement.class);
+ Mockito.when(connection.getMetaData()).thenReturn(metadata);
+ Mockito.when(metadata.getColumns(null, null, "iceberg_tables",
"iceberg_type"))
+ .thenReturn(columns);
+ Mockito.when(columns.next()).thenReturn(false);
+
Mockito.when(connection.prepareStatement(Mockito.anyString())).thenReturn(alterTable);
+ Mockito.when(alterTable.execute())
+ .thenThrow(new SQLSyntaxErrorException("Duplicate column name
'iceberg_type'"));
+
+ JdbcClientPool clientPool = new TestJdbcClientPool(connection);
+ ClosableJdbcCatalog catalog =
+ new ClosableJdbcCatalog(properties -> fileIO, properties ->
clientPool, false);
+ Map<String, String> properties = newJdbcCatalogProperties();
+ properties.put(IcebergConstants.ICEBERG_JDBC_SCHEMA_VERSION, "V1");
+
+ Assertions.assertThrows(
+ UncheckedSQLException.class, () -> catalog.initialize("test",
properties));
+
+ Assertions.assertTrue(clientPool.isClosed());
+ Mockito.verify(connection).close();
+ Mockito.verify(fileIO).close();
+ }
+
@Test
void testKerberosInitNoConf() {
ClosableJdbcCatalog catalog = new ClosableJdbcCatalog();
@@ -131,4 +170,19 @@ public class TestClosableJdbcCatalog {
properties.put(IcebergConstants.ICEBERG_JDBC_INITIALIZE, "true");
return properties;
}
+
+ private static class TestJdbcClientPool extends JdbcClientPool {
+ private final Connection connection;
+
+ private TestJdbcClientPool(Connection connection) {
+ super(1, "jdbc:test", Collections.emptyMap());
+ this.connection = connection;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected Connection newClient() {
+ return connection;
+ }
+ }
}
diff --git
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
index 1b302f4437..79e18b5028 100644
---
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
+++
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
@@ -20,6 +20,8 @@
package org.apache.gravitino.iceberg.common.utils;
import java.nio.file.Path;
+import java.sql.SQLException;
+import java.sql.SQLSyntaxErrorException;
import java.util.HashMap;
import java.util.Map;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergCatalogBackend;
@@ -36,6 +38,7 @@ import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.hive.HiveCatalog;
import org.apache.iceberg.inmemory.InMemoryCatalog;
import org.apache.iceberg.jdbc.JdbcCatalogWithMetadataLocationSupport;
+import org.apache.iceberg.jdbc.UncheckedSQLException;
import org.apache.iceberg.types.Types;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -290,4 +293,49 @@ public class TestIcebergCatalogUtil {
Assertions.assertEquals(
"6789",
properties.get(IcebergConstants.ICEBERG_REST_CLIENT_SOCKET_TIMEOUT_MS));
}
+
+ @Test
+ void testIsConcurrentViewMigrationConflictAcrossDatabases() {
+ // The duplicate `iceberg_type` column error wording differs per backend
database; each of these
+ // means another Iceberg JDBC catalog on the same `uri` already ran the V1
view migration.
+ Assertions.assertTrue(
+ IcebergCatalogUtil.isConcurrentViewMigrationConflict(
+ migrationError("Duplicate column name 'iceberg_type'")),
+ "MySQL duplicate-column error should be treated as a concurrent
view-migration conflict");
+ Assertions.assertTrue(
+ IcebergCatalogUtil.isConcurrentViewMigrationConflict(
+ migrationError(
+ "ERROR: column \"iceberg_type\" of relation \"iceberg_tables\"
already exists")),
+ "PostgreSQL duplicate-column error should be treated as a conflict");
+ Assertions.assertTrue(
+ IcebergCatalogUtil.isConcurrentViewMigrationConflict(
+ migrationError("duplicate column name: iceberg_type")),
+ "SQLite duplicate-column error should be treated as a conflict");
+ }
+
+ @Test
+ void testIsConcurrentViewMigrationConflictRejectsUnrelatedErrors() {
+ // A duplicate-column error on a different column is not the view
migration.
+ Assertions.assertFalse(
+ IcebergCatalogUtil.isConcurrentViewMigrationConflict(
+ migrationError("Duplicate column name 'some_other_column'")));
+ // An `iceberg_type` mention without a duplicate/already-exists signal is
not this conflict.
+ Assertions.assertFalse(
+ IcebergCatalogUtil.isConcurrentViewMigrationConflict(
+ migrationError("Unknown column 'iceberg_type' in 'field list'")));
+ // A non-SQL cause is never this conflict.
+ Assertions.assertFalse(
+ IcebergCatalogUtil.isConcurrentViewMigrationConflict(
+ new UncheckedSQLException(
+ new RuntimeException("Duplicate column name 'iceberg_type'"),
"boom")));
+ // A null cause message must not throw.
+ Assertions.assertFalse(
+ IcebergCatalogUtil.isConcurrentViewMigrationConflict(
+ new UncheckedSQLException(new SQLException((String) null),
"boom")));
+ }
+
+ private static UncheckedSQLException migrationError(String causeMessage) {
+ return new UncheckedSQLException(
+ new SQLSyntaxErrorException(causeMessage), "Cannot check and
eventually update SQL schema");
+ }
}