Skip to content

Commit 7b9a645

Browse files
committed
fix(iceberg): create supporting index on iceberg_tables for PostgreSQL JDBC catalogs at scale
JdbcCatalog's namespace/table existence checks query iceberg_tables with an OR(exact match, LIKE prefix-match) predicate over (catalog_name, table_namespace). The table's only index is its primary key (catalog_name, table_namespace, table_name), which cannot serve a LIKE range scan under PostgreSQL's default locale-aware b-tree operator class, so this degrades to a full sequential scan once the table holds a large number of rows - observed at ~19.5ms per check and 100-570x throughput loss on NAMESPACE_CREATE/DROP/READ, TABLE_LIST/CREATE/DROP at 100,000 rows, confirmed via EXPLAIN ANALYZE. JdbcCatalogWithMetadataLocationSupport.initialize() now creates CREATE INDEX IF NOT EXISTS gravitino_iceberg_tables_namespace_pattern ON iceberg_tables (catalog_name, table_namespace text_pattern_ops) once per catalog initialization, detected via DatabaseMetaData so it's a no-op for MySQL/SQLite/H2. Controlled by the new jdbc.create-namespace-index property (default enabled) for operators whose database role lacks CREATE INDEX privileges. Never fails catalog initialization on error - a missing index is a performance issue, not a correctness one.
1 parent bb6b039 commit 7b9a645

7 files changed

Lines changed: 240 additions & 0 deletions

File tree

catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ public class IcebergConstants {
4040

4141
public static final String ICEBERG_JDBC_STRICT_MODE = "jdbc.strict-mode";
4242

43+
public static final String ICEBERG_JDBC_CREATE_NAMESPACE_INDEX = "jdbc.create-namespace-index";
44+
4345
public static final String GRAVITINO_JDBC_DRIVER = "jdbc-driver";
4446
public static final String WAREHOUSE = "warehouse";
4547
public static final String URI = "uri";

docs/iceberg-rest-service.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ For Kerberos-secured Hive Metastore and HDFS, see [Backend Authentication](#back
179179
| `gravitino.iceberg-rest.jdbc-driver` | `com.mysql.jdbc.Driver` or `com.mysql.cj.jdbc.Driver` for MySQL, `org.postgresql.Driver` for PostgreSQL. | (none) | Yes |
180180
| `gravitino.iceberg-rest.jdbc-schema-version` | The schema version of the JDBC catalog. Defaults to `V1` to enable view support. Set to `V0` only if you need to opt out of view support. Once the underlying database is migrated to V1, this property is no longer required on subsequent restarts. | `V1` | No |
181181
| `gravitino.iceberg-rest.jdbc.strict-mode` | Whether the JDBC catalog runs in strict mode. Defaults to `true` so that creating a table or view in a namespace that does not exist fails with `NoSuchNamespace` (HTTP 404), matching the Iceberg REST specification. Set to `false` to restore the legacy behavior of implicitly creating the namespace. | `true` | No |
182+
| `gravitino.iceberg-rest.jdbc.create-namespace-index` | Whether to create a supporting index on the shared `iceberg_tables` control table for PostgreSQL backends. Without it, every namespace/table existence check (and therefore every namespace create/drop) becomes a sequential scan of the whole table once it holds a large number of rows, since PostgreSQL's default index operator class can't bound the catalog's `LIKE`-prefix namespace query. No-op for non-PostgreSQL backends. Set to `false` if your database role lacks `CREATE INDEX` privileges or you manage indexes yourself. | `true` | No |
182183

183184
If you have a JDBC Iceberg catalog prior, you must set `catalog-backend-name` to keep consistent with your Jdbc Iceberg catalog name to operate the prior namespace and tables.
184185

iceberg/iceberg-common/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,14 @@ dependencies {
110110
compileOnly(libs.lombok)
111111

112112
testImplementation(project(":server-common"))
113+
testImplementation(project(":integration-test-common", "testArtifacts"))
113114
testImplementation(libs.junit.jupiter.api)
114115
testImplementation(libs.junit.jupiter.params)
115116
testImplementation(libs.mockito.core)
116117
testImplementation(libs.sqlite.jdbc)
118+
testImplementation(libs.postgresql.driver)
119+
testImplementation(libs.testcontainers)
120+
testImplementation(libs.testcontainers.postgresql)
117121

118122
testRuntimeOnly(libs.junit.jupiter.engine)
119123
}

iceberg/iceberg-common/src/main/java/org/apache/iceberg/jdbc/JdbcCatalogWithMetadataLocationSupport.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,18 @@
2121

2222
import com.google.common.base.Preconditions;
2323
import java.sql.SQLException;
24+
import java.sql.Statement;
2425
import java.util.Map;
2526
import org.apache.commons.lang3.reflect.FieldUtils;
27+
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
2628
import org.apache.gravitino.iceberg.common.ClosableJdbcCatalog;
2729
import org.apache.gravitino.iceberg.common.cache.SupportsMetadataLocation;
2830
import org.apache.iceberg.MetastoreRegisterTableUtils;
2931
import org.apache.iceberg.Table;
3032
import org.apache.iceberg.catalog.TableIdentifier;
3133
import org.apache.iceberg.exceptions.CommitFailedException;
3234
import org.apache.iceberg.jdbc.JdbcUtil.SchemaVersion;
35+
import org.apache.iceberg.util.PropertyUtil;
3336
import org.slf4j.Logger;
3437
import org.slf4j.LoggerFactory;
3538

@@ -39,6 +42,15 @@ public class JdbcCatalogWithMetadataLocationSupport extends ClosableJdbcCatalog
3942
private static final Logger LOG =
4043
LoggerFactory.getLogger(JdbcCatalogWithMetadataLocationSupport.class);
4144

45+
/**
46+
* Name of the supporting index created on {@value JdbcUtil#CATALOG_TABLE_VIEW_NAME} for
47+
* PostgreSQL backends. See {@link #maybeCreateNamespaceIndex(Map)} for why it's needed.
48+
*/
49+
private static final String NAMESPACE_INDEX_NAME =
50+
"gravitino_iceberg_tables_namespace_pattern";
51+
52+
private static final String POSTGRESQL_PRODUCT_NAME = "PostgreSQL";
53+
4254
private String jdbcCatalogName;
4355
private JdbcClientPool jdbcConnections;
4456
private SchemaVersion jdbcSchemaVersion;
@@ -51,6 +63,7 @@ public JdbcCatalogWithMetadataLocationSupport(boolean initializeCatalogTables) {
5163
public void initialize(String name, Map<String, String> properties) {
5264
super.initialize(name, properties);
5365
loadFields();
66+
maybeCreateNamespaceIndex(properties);
5467
}
5568

5669
@Override
@@ -120,6 +133,64 @@ private void overwriteMetadataLocation(
120133
}
121134
}
122135

136+
/**
137+
* Creates a supporting index for the hierarchical-namespace existence and listing queries
138+
* Iceberg's {@code JdbcCatalog} issues against {@value JdbcUtil#CATALOG_TABLE_VIEW_NAME} - an
139+
* {@code OR(exact match, LIKE prefix-match)} predicate over {@value JdbcUtil#CATALOG_NAME} and
140+
* {@value JdbcUtil#TABLE_NAMESPACE} that the table's primary key alone can only serve as a
141+
* sequential scan, since a plain b-tree index built with PostgreSQL's default locale-aware
142+
* operator class cannot bound a {@code LIKE} range scan. At a large number of tables this makes
143+
* every namespace/table existence check, and therefore every namespace create/drop, scan the
144+
* whole table.
145+
*
146+
* <p>PostgreSQL-only: detected at runtime via {@link java.sql.DatabaseMetaData}, so this is a
147+
* no-op for MySQL, SQLite, H2, and any other JDBC backend. Controlled by {@link
148+
* IcebergConstants#ICEBERG_JDBC_CREATE_NAMESPACE_INDEX} (default enabled). Never fails catalog
149+
* initialization: a missing index is a performance issue, not a correctness one, so an operator
150+
* whose database role lacks DDL privileges should still be able to start the catalog - a warning
151+
* is logged instead.
152+
*
153+
* @param properties the properties passed to {@link #initialize(String, Map)}
154+
*/
155+
private void maybeCreateNamespaceIndex(Map<String, String> properties) {
156+
if (!PropertyUtil.propertyAsBoolean(
157+
properties, IcebergConstants.ICEBERG_JDBC_CREATE_NAMESPACE_INDEX, true)) {
158+
return;
159+
}
160+
161+
try {
162+
jdbcConnections.run(
163+
conn -> {
164+
if (!POSTGRESQL_PRODUCT_NAME.equals(conn.getMetaData().getDatabaseProductName())) {
165+
return null;
166+
}
167+
String sql =
168+
"CREATE INDEX IF NOT EXISTS "
169+
+ NAMESPACE_INDEX_NAME
170+
+ " ON "
171+
+ JdbcUtil.CATALOG_TABLE_VIEW_NAME
172+
+ " ("
173+
+ JdbcUtil.CATALOG_NAME
174+
+ ", "
175+
+ JdbcUtil.TABLE_NAMESPACE
176+
+ " text_pattern_ops)";
177+
try (Statement stmt = conn.createStatement()) {
178+
stmt.execute(sql);
179+
}
180+
return null;
181+
});
182+
} catch (Exception e) {
183+
LOG.warn(
184+
"Failed to create supporting index {} on {}; namespace/table existence checks may "
185+
+ "become slow as the catalog grows. This does not affect correctness and catalog "
186+
+ "initialization is continuing. Set {}=false to silence this warning.",
187+
NAMESPACE_INDEX_NAME,
188+
JdbcUtil.CATALOG_TABLE_VIEW_NAME,
189+
IcebergConstants.ICEBERG_JDBC_CREATE_NAMESPACE_INDEX,
190+
e);
191+
}
192+
}
193+
123194
private void loadFields() {
124195
try {
125196
this.jdbcCatalogName = (String) FieldUtils.readField(this, "catalogName", true);

iceberg/iceberg-common/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalogWithMetadataLocationSupport.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.nio.file.Files;
2424
import java.util.HashMap;
2525
import java.util.Map;
26+
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
2627
import org.apache.iceberg.BaseTable;
2728
import org.apache.iceberg.CatalogProperties;
2829
import org.apache.iceberg.Schema;
@@ -55,6 +56,40 @@ void testLoadFields() {
5556
"warehouse")));
5657
}
5758

59+
@Test
60+
void testNamespaceIndexCreationSkippedForNonPostgresBackend() {
61+
// maybeCreateNamespaceIndex() detects the backend via DatabaseMetaData#getDatabaseProductName
62+
// and only runs its (PostgreSQL-only) CREATE INDEX statement for "PostgreSQL". Initializing
63+
// against SQLite must not throw even though this new code path runs unconditionally on every
64+
// initialize() call - it's a no-op here, not skipped entirely.
65+
JdbcCatalogWithMetadataLocationSupport catalog =
66+
new JdbcCatalogWithMetadataLocationSupport(true);
67+
Map<String, String> properties = new HashMap<>();
68+
properties.put(CatalogProperties.URI, "jdbc:sqlite::memory:");
69+
properties.put(CatalogProperties.WAREHOUSE_LOCATION, "warehouse");
70+
71+
Assertions.assertDoesNotThrow(
72+
() -> catalog.initialize("test_namespace_index_sqlite", properties));
73+
}
74+
75+
@Test
76+
void testNamespaceIndexCreationCanBeDisabledWithoutError() {
77+
// With the feature disabled, initialize() must still succeed on a backend where the feature
78+
// would otherwise be a no-op anyway (SQLite) - this only tests that the disable flag itself
79+
// doesn't break anything. Coverage that the index is actually skipped on a real PostgreSQL
80+
// backend when disabled lives in
81+
// TestJdbcCatalogWithMetadataLocationSupportNamespaceIndex#testIndexCreationCanBeDisabled.
82+
JdbcCatalogWithMetadataLocationSupport catalog =
83+
new JdbcCatalogWithMetadataLocationSupport(true);
84+
Map<String, String> properties = new HashMap<>();
85+
properties.put(CatalogProperties.URI, "jdbc:sqlite::memory:");
86+
properties.put(CatalogProperties.WAREHOUSE_LOCATION, "warehouse");
87+
properties.put(IcebergConstants.ICEBERG_JDBC_CREATE_NAMESPACE_INDEX, "false");
88+
89+
Assertions.assertDoesNotThrow(
90+
() -> catalog.initialize("test_namespace_index_disabled_sqlite", properties));
91+
}
92+
5893
@ParameterizedTest
5994
@CsvSource({
6095
"V0, false, JDBC catalog with V0 schema version should not support views",
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.iceberg.jdbc;
21+
22+
import java.nio.file.Files;
23+
import java.sql.Connection;
24+
import java.sql.DriverManager;
25+
import java.sql.ResultSet;
26+
import java.sql.Statement;
27+
import java.util.HashMap;
28+
import java.util.Map;
29+
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
30+
import org.apache.gravitino.integration.test.container.ContainerSuite;
31+
import org.apache.gravitino.integration.test.container.PostgreSQLContainer;
32+
import org.apache.gravitino.integration.test.util.TestDatabaseName;
33+
import org.apache.iceberg.CatalogProperties;
34+
import org.junit.jupiter.api.Assertions;
35+
import org.junit.jupiter.api.BeforeAll;
36+
import org.junit.jupiter.api.Tag;
37+
import org.junit.jupiter.api.Test;
38+
39+
/**
40+
* Verifies that {@link JdbcCatalogWithMetadataLocationSupport} creates the {@code
41+
* iceberg_tables}-namespace supporting index against a real PostgreSQL backend, and that it can be
42+
* disabled via {@link IcebergConstants#ICEBERG_JDBC_CREATE_NAMESPACE_INDEX}.
43+
*
44+
* <p>Runs against SQLite too (via {@link
45+
* TestJdbcCatalogWithMetadataLocationSupport#testLoadFields()} and friends, in the sibling
46+
* non-docker test class) to confirm the feature is a no-op for non-PostgreSQL backends; that
47+
* coverage isn't duplicated here.
48+
*/
49+
@Tag("gravitino-docker-test")
50+
public class TestJdbcCatalogWithMetadataLocationSupportNamespaceIndex {
51+
52+
private static final String INDEX_NAME = "gravitino_iceberg_tables_namespace_pattern";
53+
54+
private static PostgreSQLContainer postgreSQLContainer;
55+
56+
@BeforeAll
57+
public static void startPostgres() {
58+
ContainerSuite containerSuite = ContainerSuite.getInstance();
59+
containerSuite.startPostgreSQLContainer(TestDatabaseName.PG_ICEBERG_NAMESPACE_INDEX_IT);
60+
postgreSQLContainer = containerSuite.getPostgreSQLContainer();
61+
}
62+
63+
@Test
64+
void testIndexCreatedByDefault() throws Exception {
65+
String jdbcUrl =
66+
postgreSQLContainer.getJdbcUrl(TestDatabaseName.PG_ICEBERG_NAMESPACE_INDEX_IT);
67+
68+
JdbcCatalogWithMetadataLocationSupport catalog =
69+
new JdbcCatalogWithMetadataLocationSupport(true);
70+
catalog.initialize("test_index_default", newProperties(jdbcUrl));
71+
72+
Assertions.assertTrue(
73+
indexExists(jdbcUrl), "Expected " + INDEX_NAME + " to be created by default");
74+
}
75+
76+
@Test
77+
void testIndexCreationCanBeDisabled() throws Exception {
78+
String jdbcUrl =
79+
postgreSQLContainer.getJdbcUrl(TestDatabaseName.PG_ICEBERG_NAMESPACE_INDEX_IT);
80+
81+
Map<String, String> properties = newProperties(jdbcUrl);
82+
properties.put(IcebergConstants.ICEBERG_JDBC_CREATE_NAMESPACE_INDEX, "false");
83+
84+
JdbcCatalogWithMetadataLocationSupport catalog =
85+
new JdbcCatalogWithMetadataLocationSupport(true);
86+
catalog.initialize("test_index_disabled", properties);
87+
88+
Assertions.assertFalse(
89+
indexExists(jdbcUrl),
90+
"Expected " + INDEX_NAME + " not to be created when explicitly disabled");
91+
}
92+
93+
private boolean indexExists(String jdbcUrl) throws Exception {
94+
try (Connection conn =
95+
DriverManager.getConnection(
96+
jdbcUrl, postgreSQLContainer.getUsername(), postgreSQLContainer.getPassword());
97+
Statement stmt = conn.createStatement();
98+
ResultSet rs =
99+
stmt.executeQuery(
100+
"SELECT 1 FROM pg_indexes WHERE indexname = '" + INDEX_NAME + "'")) {
101+
return rs.next();
102+
}
103+
}
104+
105+
private Map<String, String> newProperties(String jdbcUrl) throws Exception {
106+
Map<String, String> properties = new HashMap<>();
107+
properties.put(CatalogProperties.URI, jdbcUrl);
108+
properties.put(
109+
CatalogProperties.WAREHOUSE_LOCATION,
110+
Files.createTempDirectory("jdbc-namespace-index-it").toString());
111+
properties.put(IcebergConstants.ICEBERG_JDBC_USER, postgreSQLContainer.getUsername());
112+
properties.put(IcebergConstants.ICEBERG_JDBC_PASSWORD, postgreSQLContainer.getPassword());
113+
return properties;
114+
}
115+
}

integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ public String toString() {
149149
return this.name().toLowerCase();
150150
}
151151
},
152+
153+
/**
154+
* Represents the PostgreSQL database for
155+
* org.apache.iceberg.jdbc.TestJdbcCatalogWithMetadataLocationSupportNamespaceIndex.
156+
*/
157+
PG_ICEBERG_NAMESPACE_INDEX_IT {
158+
/** PostgreSQL only accept lowercase database name */
159+
@Override
160+
public String toString() {
161+
return this.name().toLowerCase();
162+
}
163+
},
152164
FLINK_HIVE_CATALOG_IT,
153165

154166
/** Represents the MySQL database for the Flink Iceberg JDBC-backend catalog integration test. */

0 commit comments

Comments
 (0)