diff --git a/.github/workflows/diskquota-jdbc-integration.yml b/.github/workflows/diskquota-jdbc-integration.yml new file mode 100644 index 000000000..63ec78c38 --- /dev/null +++ b/.github/workflows/diskquota-jdbc-integration.yml @@ -0,0 +1,43 @@ +name: DiskQuota JDBC Integration + +on: + push: + tags: + - "**" + pull_request: + paths: + - ".github/workflows/diskquota-jdbc-integration.yml" + - "pom.xml" + - "geowebcache/pom.xml" + - "geowebcache/core/**" + - "geowebcache/diskquota/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + testcontainers: + name: DiskQuota JDBC Testcontainers (Postgres + Oracle XE) + runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 17, 21 ] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: ${{ matrix.java-version }} + cache: 'maven' + + - name: Tests against PostgreSQL and Oracle XE TestContainers + run: | + mvn verify -f geowebcache/pom.xml -pl :gwc-diskquota-jdbc -am \ + -Ponline \ + -DskipTests=true \ + -DskipITs=false -B -ntp + + - name: Remove SNAPSHOT jars from repository + run: | + find .m2/repository -name "*SNAPSHOT*" -type d | xargs rm -rf {} diff --git a/geowebcache/diskquota/jdbc/pom.xml b/geowebcache/diskquota/jdbc/pom.xml index 8bfbd94e3..c437725f7 100644 --- a/geowebcache/diskquota/jdbc/pom.xml +++ b/geowebcache/diskquota/jdbc/pom.xml @@ -58,6 +58,30 @@ mockito-core test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + oracle-xe + test + + + + com.oracle.database.jdbc + ojdbc11 + 23.4.0.24.05 + test + @@ -76,5 +100,45 @@ + + + online + + false + + + + + maven-failsafe-plugin + + 1 + false + + + + + + + + excludeDockerTests + + false + + + + + maven-failsafe-plugin + + 1 + false + + org.geowebcache.diskquota.jdbc.tests.container.*IT + + + + + + diff --git a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/HSQLDialect.java b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/HSQLDialect.java index 41cf8cd18..3a83313fb 100644 --- a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/HSQLDialect.java +++ b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/HSQLDialect.java @@ -13,7 +13,7 @@ */ package org.geowebcache.diskquota.jdbc; -import java.util.Arrays; +import java.util.List; /** * HSQL dialect for the quota store @@ -25,69 +25,44 @@ public HSQLDialect() { TABLE_CREATION_MAP.put( "TILESET", - Arrays.asList( // - "CREATE CACHED TABLE ${schema}TILESET (\n" - + // - " KEY VARCHAR(" - + TILESET_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " LAYER_NAME VARCHAR(" - + LAYER_NAME_SIZE - + "),\n" - + // - " GRIDSET_ID VARCHAR(" - + GRIDSET_ID_SIZE - + "),\n" - + // - " BLOB_FORMAT VARCHAR(" - + BLOB_FORMAT_SIZE - + "),\n" - + // - " PARAMETERS_ID VARCHAR(" - + PARAMETERS_ID_SIZE - + "),\n" - + // - " BYTES NUMERIC(" - + BYTES_SIZE - + ") DEFAULT 0 NOT NULL\n" - + // - ")", // + List.of( // + """ + CREATE CACHED TABLE ${schema}TILESET ( + KEY VARCHAR(%d) PRIMARY KEY, + LAYER_NAME VARCHAR(%d), + GRIDSET_ID VARCHAR(%d), + BLOB_FORMAT VARCHAR(%d), + PARAMETERS_ID VARCHAR(%d), + BYTES NUMERIC(%d) DEFAULT 0 NOT NULL + ) + """ + .formatted( + TILESET_KEY_SIZE, + LAYER_NAME_SIZE, + GRIDSET_ID_SIZE, + BLOB_FORMAT_SIZE, + PARAMETERS_ID_SIZE, + BYTES_SIZE), // "CREATE INDEX TILESET_LAYER ON ${schema}TILESET(LAYER_NAME)" // )); TABLE_CREATION_MAP.put( "TILEPAGE", - Arrays.asList( - "CREATE CACHED TABLE ${schema}TILEPAGE (\n" - + // - " KEY VARCHAR(" - + TILEPAGE_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " TILESET_ID VARCHAR(" - + TILESET_KEY_SIZE - + ") REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE,\n" - + // - " PAGE_Z SMALLINT,\n" - + // - " PAGE_X INTEGER,\n" - + // - " PAGE_Y INTEGER,\n" - + // - " CREATION_TIME_MINUTES INTEGER,\n" - + // - " FREQUENCY_OF_USE FLOAT,\n" - + // - " LAST_ACCESS_TIME_MINUTES INTEGER,\n" - + // - " FILL_FACTOR FLOAT,\n" - + // - " NUM_HITS NUMERIC(" - + NUM_HITS_SIZE - + ")\n" - + // - ")", // + List.of( + """ + CREATE CACHED TABLE ${schema}TILEPAGE ( + KEY VARCHAR(%d) PRIMARY KEY, + TILESET_ID VARCHAR(%d) REFERENCES ${schema}TILESET(KEY) ON UPDATE CASCADE ON DELETE CASCADE, + PAGE_Z SMALLINT, + PAGE_X INTEGER, + PAGE_Y INTEGER, + CREATION_TIME_MINUTES INTEGER, + FREQUENCY_OF_USE FLOAT, + LAST_ACCESS_TIME_MINUTES INTEGER, + FILL_FACTOR FLOAT, + NUM_HITS NUMERIC(%d) + )""" + .formatted(TILEPAGE_KEY_SIZE, TILESET_KEY_SIZE, NUM_HITS_SIZE), // "CREATE INDEX TILEPAGE_TILESET ON ${schema}TILEPAGE(TILESET_ID, FILL_FACTOR)", "CREATE INDEX TILEPAGE_FREQUENCY ON ${schema}TILEPAGE(FREQUENCY_OF_USE DESC)", "CREATE INDEX TILEPAGE_LAST_ACCESS ON ${schema}TILEPAGE(LAST_ACCESS_TIME_MINUTES DESC)")); diff --git a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java index a737a5cb6..b1604d057 100644 --- a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java +++ b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java @@ -13,7 +13,6 @@ */ package org.geowebcache.diskquota.jdbc; -import java.util.Arrays; import java.util.List; /** @@ -34,69 +33,45 @@ static int numberPrecision(int n) { public OracleDialect() { TABLE_CREATION_MAP.put( "TILESET", - Arrays.asList( // - "CREATE TABLE ${schema}TILESET (\n" - + // - " KEY VARCHAR(" - + TILESET_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " LAYER_NAME VARCHAR(" - + LAYER_NAME_SIZE - + "),\n" - + // - " GRIDSET_ID VARCHAR(" - + GRIDSET_ID_SIZE - + "),\n" - + // - " BLOB_FORMAT VARCHAR(" - + BLOB_FORMAT_SIZE - + "),\n" - + // - " PARAMETERS_ID VARCHAR(" - + PARAMETERS_ID_SIZE - + "),\n" - + // - " BYTES NUMBER(" - + numberPrecision(BYTES_SIZE) - + ") DEFAULT 0 NOT NULL\n" - + // - ") ORGANIZATION INDEX", // + List.of( // + """ + CREATE TABLE ${schema}TILESET ( + KEY VARCHAR(%d) PRIMARY KEY, + LAYER_NAME VARCHAR(%d), + GRIDSET_ID VARCHAR(%d), + BLOB_FORMAT VARCHAR(%d), + PARAMETERS_ID VARCHAR(%d), + BYTES NUMBER(%d) DEFAULT 0 NOT NULL + ) ORGANIZATION INDEX + """ + .formatted( + TILESET_KEY_SIZE, + LAYER_NAME_SIZE, + GRIDSET_ID_SIZE, + BLOB_FORMAT_SIZE, + PARAMETERS_ID_SIZE, + numberPrecision(BYTES_SIZE)), // "CREATE INDEX TILESET_LAYER ON TILESET(LAYER_NAME)" // )); TABLE_CREATION_MAP.put( "TILEPAGE", - Arrays.asList( - "CREATE TABLE ${schema}TILEPAGE (\n" - + // - " KEY VARCHAR(" - + TILEPAGE_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " TILESET_ID VARCHAR(" - + TILESET_KEY_SIZE - + ") REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE,\n" - + // - " PAGE_Z SMALLINT,\n" - + // - " PAGE_X INTEGER,\n" - + // - " PAGE_Y INTEGER,\n" - + // - " CREATION_TIME_MINUTES INTEGER,\n" - + // - " FREQUENCY_OF_USE FLOAT,\n" - + // - " LAST_ACCESS_TIME_MINUTES INTEGER,\n" - + // - " FILL_FACTOR FLOAT,\n" - + // - " NUM_HITS NUMBER(" - + numberPrecision(NUM_HITS_SIZE) - + ")\n" - + // - ") ORGANIZATION INDEX", // + List.of( + """ + CREATE TABLE ${schema}TILEPAGE ( + KEY VARCHAR(%d) PRIMARY KEY, + TILESET_ID VARCHAR(%d) REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE, + PAGE_Z SMALLINT, + PAGE_X INTEGER, + PAGE_Y INTEGER, + CREATION_TIME_MINUTES INTEGER, + FREQUENCY_OF_USE FLOAT, + LAST_ACCESS_TIME_MINUTES INTEGER, + FILL_FACTOR FLOAT, + NUM_HITS NUMBER(%d) + ) ORGANIZATION INDEX + """ + .formatted(TILEPAGE_KEY_SIZE, TILESET_KEY_SIZE, numberPrecision(NUM_HITS_SIZE)), // "CREATE INDEX TILEPAGE_TILESET ON TILEPAGE(TILESET_ID)", "CREATE INDEX TILEPAGE_FILL_FACTOR ON TILEPAGE(FILL_FACTOR)", "CREATE INDEX TILEPAGE_FREQUENCY ON TILEPAGE(FREQUENCY_OF_USE DESC)", @@ -108,6 +83,40 @@ protected void addEmtpyTableReference(StringBuilder sb) { sb.append("FROM DUAL"); } + /** + * No-op: Oracle does not support {@code ON UPDATE CASCADE} on foreign keys, so there is nothing portable to + * migrate. Companion to {@link #getRenameLayerStatement(String, String, String)}, which preserves the legacy + * LAYER_NAME-only behavior on this dialect. + */ + @Override + public void migrateForeignKeys(String schema, SimpleJdbcTemplate template) { + // intentional no-op + } + + /** + * Oracle does not support {@code ON UPDATE CASCADE} on foreign keys, so the {@code TILEPAGE.TILESET_ID -> TILESET + * .KEY} FK declared above only cascades on delete. As a result this dialect cannot safely rewrite {@code TILESET + * .KEY} during a rename without first dealing with the dangling {@code TILEPAGE} rows. + * + *

For now Oracle keeps the legacy behavior of only updating {@code LAYER_NAME}; lookups by id against the + * renamed layer will continue to miss the row and cause {@code getOrCreateTileSet} to insert duplicates. Fixing + * this on Oracle (e.g. via {@code DEFERRABLE INITIALLY DEFERRED} constraints, or by disabling the FK around the + * rename) is tracked separately. + */ + @Override + public String getRenameLayerStatement(String schema, String oldLayerName, String newLayerName) { + StringBuilder sb = new StringBuilder("UPDATE "); + if (schema != null) { + sb.append(schema).append("."); + } + sb.append("TILESET SET LAYER_NAME = :") + .append(newLayerName) + .append(" WHERE LAYER_NAME = :") + .append(oldLayerName); + + return sb.toString(); + } + @Override public String getLeastFrequentlyUsedPage(String schema, List layerParamNames) { StringBuilder sb = new StringBuilder("SELECT * FROM ("); diff --git a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/SQLDialect.java b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/SQLDialect.java index cc6b56a3f..4faa40608 100644 --- a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/SQLDialect.java +++ b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/SQLDialect.java @@ -13,12 +13,19 @@ */ package org.geowebcache.diskquota.jdbc; +import java.sql.DatabaseMetaData; import java.sql.ResultSet; -import java.util.Arrays; +import java.sql.SQLException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.sql.DataSource; +import org.geotools.util.logging.Logging; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.support.JdbcAccessor; import org.springframework.jdbc.support.JdbcUtils; import org.springframework.jdbc.support.MetaDataAccessException; @@ -31,6 +38,8 @@ */ public class SQLDialect { + private static final Logger LOG = Logging.getLogger(SQLDialect.class); + // size guesses: 128 characters should be more than enough for layer name, the gridset id // is normally an epsg code so 32 is way more than enough, the blob format // is normally a mime plus some extras, again 64 should fit, a param id is @@ -54,70 +63,45 @@ public class SQLDialect { { put( "TILESET", - Arrays.asList( // - "CREATE TABLE ${schema}TILESET (\n" - + // - " KEY VARCHAR(" - + TILESET_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " LAYER_NAME VARCHAR(" - + LAYER_NAME_SIZE - + "),\n" - + // - " GRIDSET_ID VARCHAR(" - + GRIDSET_ID_SIZE - + "),\n" - + // - " BLOB_FORMAT VARCHAR(" - + BLOB_FORMAT_SIZE - + "),\n" - + // - " PARAMETERS_ID VARCHAR(" - + PARAMETERS_ID_SIZE - + "),\n" - + // - " BYTES NUMERIC(" - + BYTES_SIZE - + ") NOT NULL DEFAULT 0\n" - + // - ")", // + List.of( + """ + CREATE TABLE ${schema}TILESET ( + KEY VARCHAR(%d) PRIMARY KEY, + LAYER_NAME VARCHAR(%d), + GRIDSET_ID VARCHAR(%d), + BLOB_FORMAT VARCHAR(%d), + PARAMETERS_ID VARCHAR(%d), + BYTES NUMERIC(%d) NOT NULL DEFAULT 0 + )""" + .formatted( + TILESET_KEY_SIZE, + LAYER_NAME_SIZE, + GRIDSET_ID_SIZE, + BLOB_FORMAT_SIZE, + PARAMETERS_ID_SIZE, + BYTES_SIZE), // "CREATE INDEX TILESET_LAYER ON ${schema}TILESET(LAYER_NAME)" // )); // this one embeds both tile page and page stats, since they are linked 1-1 put( "TILEPAGE", - Arrays.asList( - "CREATE TABLE ${schema}TILEPAGE (\n" - + // - " KEY VARCHAR(" - + TILEPAGE_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " TILESET_ID VARCHAR(" - + TILESET_KEY_SIZE - + ") REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE,\n" - + // - " PAGE_Z SMALLINT,\n" - + // - " PAGE_X INTEGER,\n" - + // - " PAGE_Y INTEGER,\n" - + // - " CREATION_TIME_MINUTES INTEGER,\n" - + // - " FREQUENCY_OF_USE FLOAT,\n" - + // - " LAST_ACCESS_TIME_MINUTES INTEGER,\n" - + // - " FILL_FACTOR FLOAT,\n" - + // - " NUM_HITS NUMERIC(" - + NUM_HITS_SIZE - + ")\n" - + // - ")", // + List.of( + """ + CREATE TABLE ${schema}TILEPAGE ( + KEY VARCHAR(%d) PRIMARY KEY, + TILESET_ID VARCHAR(%d) REFERENCES ${schema}TILESET(KEY) ON UPDATE CASCADE ON DELETE CASCADE, + PAGE_Z SMALLINT, + PAGE_X INTEGER, + PAGE_Y INTEGER, + CREATION_TIME_MINUTES INTEGER, + FREQUENCY_OF_USE FLOAT, + LAST_ACCESS_TIME_MINUTES INTEGER, + FILL_FACTOR FLOAT, + NUM_HITS NUMERIC(%d) + ) + """ + .formatted(TILEPAGE_KEY_SIZE, TILESET_KEY_SIZE, NUM_HITS_SIZE), // "CREATE INDEX TILEPAGE_TILESET ON ${schema}TILEPAGE(TILESET_ID, FILL_FACTOR)", "CREATE INDEX TILEPAGE_FREQUENCY ON ${schema}TILEPAGE(FREQUENCY_OF_USE DESC)", "CREATE INDEX TILEPAGE_LAST_ACCESS ON ${schema}TILEPAGE(LAST_ACCESS_TIME_MINUTES DESC)")); @@ -140,6 +124,138 @@ public void initializeTables(String schema, SimpleJdbcTemplate template) { } } } + // Bring pre-existing installations up to the current FK contract (ON UPDATE CASCADE). + // No-op for fresh schemas created above; no-op for dialects that cannot support it. + migrateForeignKeys(schema, template); + } + + /** + * Upgrades the {@code TILEPAGE -> TILESET} foreign key on installations created before this dialect started + * declaring it {@code ON UPDATE CASCADE}, so that {@link #getRenameLayerStatement(String, String, String) renaming + * a layer} can rewrite {@code TILESET.KEY} without orphaning the dependent {@code TILEPAGE} rows. + * + *

Idempotent: looks up the existing FK via {@link DatabaseMetaData#getImportedKeys}, and only rewrites it when + * the current update rule is not {@link DatabaseMetaData#importedKeyCascade}. Dialects that cannot support + * {@code ON UPDATE CASCADE} (notably Oracle) override this method to no-op. + */ + protected void migrateForeignKeys(String schema, SimpleJdbcTemplate template) { + DataSource ds = Objects.requireNonNull(((JdbcAccessor) template.getJdbcOperations()).getDataSource()); + try { + JdbcUtils.extractDatabaseMetaData(ds, dbmd -> upgradeTilepageForeignKey(dbmd, schema, template)); + } catch (MetaDataAccessException e) { + LOG.log( + Level.WARNING, + "Could not migrate TILEPAGE foreign key to ON UPDATE CASCADE; layer renames may leave stale rows", + e); + } + } + + /** + * {@link DatabaseMetaDataCallback} body for {@link #migrateForeignKeys}: scans the FKs declared on TILEPAGE and, + * for any TILEPAGE -> TILESET FK whose update rule is not {@link DatabaseMetaData#importedKeyCascade}, drops it and + * re-adds it with {@code ON UPDATE CASCADE ON DELETE CASCADE}. Idempotent: a FK that already cascades on update is + * left untouched. + * + *

Concurrent-startup safe: if another instance races us to the upgrade, our drop/add may throw because the + * legacy constraint name no longer exists. In that case we re-check the live FK state and, if the cascade form is + * already in place, treat it as a concurrent success rather than a failure. + */ + private Void upgradeTilepageForeignKey(DatabaseMetaData dbmd, String schema, SimpleJdbcTemplate template) + throws SQLException { + + final String tilepageName = resolveTableName(dbmd, schema, "TILEPAGE"); + if (tilepageName == null) { + return null; + } + final String prefix = schema == null ? "" : schema + "."; + final String prefixedTilepageName = prefix + tilepageName; + try (ResultSet rs = dbmd.getImportedKeys(null, schema, tilepageName)) { + while (rs.next()) { + String fkName = rs.getString("FK_NAME"); + if (!isTilesetCascadeCandidate(rs, fkName)) { + continue; + } + String drop = "ALTER TABLE %s DROP CONSTRAINT %s".formatted(prefixedTilepageName, fkName); + String add = + """ + ALTER TABLE %s ADD FOREIGN KEY (TILESET_ID) + REFERENCES %sTILESET(KEY) + ON UPDATE CASCADE ON DELETE CASCADE + """ + .formatted(prefixedTilepageName, prefix); + + LOG.info(() -> "Upgrading TILEPAGE.TILESET_ID foreign key to ON UPDATE CASCADE (was constraint %s)" + .formatted(fkName)); + JdbcOperations jdbcOperations = template.getJdbcOperations(); + try { + jdbcOperations.execute(drop); + jdbcOperations.execute(add); + } catch (DataAccessException raceLikely) { + if (isTilepageFkAlreadyCascade(dbmd, schema, tilepageName)) { + LOG.fine(() -> "TILEPAGE FK was migrated concurrently by another instance " + + "while this instance was trying to drop %s; accepting concurrent migration" + .formatted(fkName)); + return null; + } + throw raceLikely; + } + } + } + return null; + } + + /** + * Re-checks the live TILEPAGE -> TILESET FK state after a failed migration attempt. Returns {@code true} when the + * FK is already declared {@link DatabaseMetaData#importedKeyCascade}, i.e. another instance has completed the + * migration in the meantime. + */ + private static boolean isTilepageFkAlreadyCascade(DatabaseMetaData dbmd, String schema, String tilepageName) + throws SQLException { + try (ResultSet rs = dbmd.getImportedKeys(null, schema, tilepageName)) { + while (rs.next()) { + String pkTable = rs.getString("PKTABLE_NAME"); + String fkColumn = rs.getString("FKCOLUMN_NAME"); + if (!"TILESET".equalsIgnoreCase(pkTable) || !"TILESET_ID".equalsIgnoreCase(fkColumn)) { + continue; + } + if (rs.getShort("UPDATE_RULE") == DatabaseMetaData.importedKeyCascade) { + return true; + } + } + } + return false; + } + + /** + * True when the current {@code getImportedKeys} row describes the TILEPAGE -> TILESET(KEY) FK and its update rule + * is something other than {@code CASCADE}. + */ + private static boolean isTilesetCascadeCandidate(ResultSet rs, String fkName) throws SQLException { + if (fkName == null || fkName.isEmpty()) { + return false; + } + String pkTable = rs.getString("PKTABLE_NAME"); + String fkColumn = rs.getString("FKCOLUMN_NAME"); + boolean isTilesetFk = "TILESET".equalsIgnoreCase(pkTable) && "TILESET_ID".equalsIgnoreCase(fkColumn); + if (!isTilesetFk) { + return false; + } + short updateRule = rs.getShort("UPDATE_RULE"); + return updateRule != DatabaseMetaData.importedKeyCascade; + } + + private static String resolveTableName(DatabaseMetaData dbmd, String schema, String tableName) throws SQLException { + try (ResultSet rs = dbmd.getTables(null, schema, tableName.toLowerCase(), null)) { + if (rs.next()) { + return rs.getString("TABLE_NAME"); + } + } + try (ResultSet rs = dbmd.getTables(null, schema, tableName, null)) { + if (rs.next()) { + return rs.getString("TABLE_NAME"); + } + } + return null; } /** Checks if the specified table exists */ @@ -310,6 +426,19 @@ public String getUsedQuotaByLayerGridset(String schema, String layerNameParam, S return sb.toString(); } + /** + * Returns the SQL to rename a layer, updating both {@code LAYER_NAME} and the layer-name prefix of {@code KEY} on + * the {@code TILESET} table. + * + *

{@code KEY} is built as {@code ##[#]}, so the leading prefix + * (up to but not including the first {@code #}) must be rewritten in lockstep with {@code LAYER_NAME}. The + * companion FK on {@code TILEPAGE.TILESET_ID} is declared {@code ON UPDATE CASCADE} so {@code TILEPAGE} rows follow + * automatically; dialects that cannot declare an updating FK cascade (notably Oracle) override this method to keep + * {@code KEY} unchanged. + * + *

Default form uses standard SQL {@code SUBSTRING ... FROM POSITION(...)}, supported by PostgreSQL, H2, and + * HSQL. Dialects with different SUBSTRING/POSITION syntax override this method. + */ public String getRenameLayerStatement(String schema, String oldLayerName, String newLayerName) { StringBuilder sb = new StringBuilder("UPDATE "); if (schema != null) { @@ -317,6 +446,9 @@ public String getRenameLayerStatement(String schema, String oldLayerName, String } sb.append("TILESET SET LAYER_NAME = :") .append(newLayerName) + .append(", KEY = :") + .append(newLayerName) + .append(" || SUBSTRING(KEY FROM POSITION('#' IN KEY))") .append(" WHERE LAYER_NAME = :") .append(oldLayerName); diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/AbstractForeignKeyMigrationTest.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/AbstractForeignKeyMigrationTest.java new file mode 100644 index 000000000..f7d95506f --- /dev/null +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/AbstractForeignKeyMigrationTest.java @@ -0,0 +1,189 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.diskquota.jdbc; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import javax.sql.DataSource; +import org.junit.Test; + +/** + * Verifies the legacy-to-current path in {@link SQLDialect#migrateForeignKeys} that drops the existing {@code TILEPAGE + * -> TILESET} foreign key (declared with only {@code ON DELETE CASCADE}) and re-adds it with {@code ON UPDATE CASCADE + * ON DELETE CASCADE}. + * + *

The current {@code JDBCQuotaStoreTest} suite always starts from fresh-DDL tables, so it only exercises the no-op + * idempotent branch of the migration; this test class fills in the upgrade path. + * + *

Each test starts from a "legacy" schema built by stripping {@code " ON UPDATE CASCADE"} from the dialect's own + * table-creation SQL (i.e. the pre-fix shape of the FK). Subclasses provide the dialect and a DataSource pointed at the + * database under test. + */ +public abstract class AbstractForeignKeyMigrationTest { + + /** Dialect under test. */ + protected abstract SQLDialect dialect(); + + /** Data source pointed at a usable database where the legacy schema can be (re)created. */ + protected abstract DataSource dataSource(); + + /** + * Recreates the legacy schema. Subclasses call this from their {@code @Before} after wiring the data source; not + * annotated so the dialect/dataSource setup ordering is always explicit. + */ + protected void recreateLegacySchema() throws SQLException { + try (Connection cx = dataSource().getConnection(); + Statement st = cx.createStatement()) { + dropIfExists(st, "TILEPAGE"); + dropIfExists(st, "TILESET"); + for (String table : dialect().TABLE_CREATION_MAP.keySet()) { + for (String ddl : dialect().TABLE_CREATION_MAP.get(table)) { + String legacy = stripCascadeOnUpdate(ddl); + st.execute(legacy); + } + } + } + } + + /** + * Reproduces the pre-fix DDL by removing the {@code ON UPDATE CASCADE} clause that was added to the TILEPAGE FK. + */ + private static String stripCascadeOnUpdate(String ddl) { + return ddl.replace("${schema}", "").replace(" ON UPDATE CASCADE", ""); + } + + private static void dropIfExists(Statement st, String table) { + try { + st.execute("DROP TABLE " + table + " CASCADE"); + } catch (SQLException ignored) { + // table may not exist on the first run; the legacy CREATEs below recreate it + } + } + + @Test + public void migrateAddsOnUpdateCascadeToTilepageForeignKey() throws SQLException { + short ruleBefore = requireTilepageFkUpdateRule(); + assertNotEquals( + "Legacy TILEPAGE FK should not yet be ON UPDATE CASCADE", + (short) DatabaseMetaData.importedKeyCascade, + ruleBefore); + + dialect().migrateForeignKeys(null, new SimpleJdbcTemplate(dataSource())); + + short ruleAfter = requireTilepageFkUpdateRule(); + assertEquals( + "Migration should rewrite TILEPAGE FK as ON UPDATE CASCADE", + (short) DatabaseMetaData.importedKeyCascade, + ruleAfter); + } + + /** + * Simulates multiple JVMs starting at the same time against a shared database with the legacy FK still in place. + * Both call {@code migrateForeignKeys} concurrently; the migration must remain idempotent end-to-end - neither call + * should propagate an exception, and the final FK state must be cascade-on-update. + */ + @Test + public void migrateIsConcurrentStartupSafe() throws Exception { + int threads = 4; + CyclicBarrier startGate = new CyclicBarrier(threads); + ExecutorService exec = Executors.newFixedThreadPool(threads); + try { + Callable migrator = () -> { + startGate.await(); + dialect().migrateForeignKeys(null, new SimpleJdbcTemplate(dataSource())); + return null; + }; + List> futures = new ArrayList<>(threads); + for (int i = 0; i < threads; i++) { + futures.add(exec.submit(migrator)); + } + List failures = new ArrayList<>(); + for (Future f : futures) { + try { + f.get(30, TimeUnit.SECONDS); + } catch (ExecutionException e) { + failures.add(e.getCause()); + } + } + if (!failures.isEmpty()) { + AssertionError ae = new AssertionError("Concurrent migrateForeignKeys threw on " + failures.size() + "/" + + threads + " threads; " + "see suppressed"); + failures.forEach(ae::addSuppressed); + throw ae; + } + } finally { + exec.shutdownNow(); + exec.awaitTermination(5, TimeUnit.SECONDS); + } + + assertEquals( + "After concurrent migration the FK should be ON UPDATE CASCADE", + (short) DatabaseMetaData.importedKeyCascade, + requireTilepageFkUpdateRule()); + } + + @Test + public void migrateIsIdempotent() throws SQLException { + SimpleJdbcTemplate template = new SimpleJdbcTemplate(dataSource()); + dialect().migrateForeignKeys(null, template); + assertEquals((short) DatabaseMetaData.importedKeyCascade, requireTilepageFkUpdateRule()); + + // Second invocation must be a no-op (FK already cascade-on-update). + dialect().migrateForeignKeys(null, template); + assertEquals((short) DatabaseMetaData.importedKeyCascade, requireTilepageFkUpdateRule()); + } + + private short requireTilepageFkUpdateRule() throws SQLException { + Short rule = lookupTilepageFkUpdateRule(); + assertNotNull("TILEPAGE -> TILESET foreign key not found in metadata", rule); + return rule; + } + + private Short lookupTilepageFkUpdateRule() throws SQLException { + try (Connection cx = dataSource().getConnection()) { + DatabaseMetaData dbmd = cx.getMetaData(); + Short rule = findTilesetFkUpdateRule(dbmd, "tilepage"); + return rule != null ? rule : findTilesetFkUpdateRule(dbmd, "TILEPAGE"); + } + } + + private static Short findTilesetFkUpdateRule(DatabaseMetaData dbmd, String tableName) throws SQLException { + try (ResultSet rs = dbmd.getImportedKeys(null, null, tableName)) { + while (rs.next()) { + String pkTable = rs.getString("PKTABLE_NAME"); + String fkColumn = rs.getString("FKCOLUMN_NAME"); + if ("TILESET".equalsIgnoreCase(pkTable) && "TILESET_ID".equalsIgnoreCase(fkColumn)) { + return rs.getShort("UPDATE_RULE"); + } + } + } + return null; + } +} diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/HSQLForeignKeyMigrationTest.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/HSQLForeignKeyMigrationTest.java new file mode 100644 index 000000000..9ca6efa38 --- /dev/null +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/HSQLForeignKeyMigrationTest.java @@ -0,0 +1,60 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.diskquota.jdbc; + +import javax.sql.DataSource; +import org.apache.commons.dbcp.BasicDataSource; +import org.junit.After; +import org.junit.Before; + +/** + * Surefire-run unit test that runs {@link AbstractForeignKeyMigrationTest} against an in-memory HSQL database. + * + *

Each test instance gets a fresh, uniquely-named in-memory database (the {@link #INSTANCE_COUNTER} suffix isolates + * parallel/repeated runs); the connection pool is closed in {@link #tearDown()} so no state leaks across tests. + */ +public class HSQLForeignKeyMigrationTest extends AbstractForeignKeyMigrationTest { + + private static int INSTANCE_COUNTER = 0; + + private BasicDataSource dataSource; + + @Before + public void setUpDataSource() throws Exception { + dataSource = new BasicDataSource(); + dataSource.setDriverClassName("org.hsqldb.jdbcDriver"); + dataSource.setUrl("jdbc:hsqldb:mem:legacy-fk-" + (++INSTANCE_COUNTER)); + dataSource.setUsername("sa"); + dataSource.setPassword(""); + recreateLegacySchema(); + } + + @After + public void tearDown() throws Exception { + if (dataSource != null) { + dataSource.close(); + dataSource = null; + } + } + + @Override + protected SQLDialect dialect() { + return new HSQLDialect(); + } + + @Override + protected DataSource dataSource() { + return dataSource; + } +} diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStoreTest.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStoreTest.java index 1edc1ed16..5404c99cf 100644 --- a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStoreTest.java +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStoreTest.java @@ -14,6 +14,8 @@ import java.io.IOException; import java.math.BigInteger; import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; @@ -211,7 +213,7 @@ protected void tearDownInternal() throws Exception { TilePageCalculator tilePageCalculator; - private BasicDataSource dataSource; + protected BasicDataSource dataSource; private TileSet testTileSet; @@ -233,22 +235,22 @@ protected JDBCFixtureRule makeFixtureRule() { protected abstract String getFixtureId(); protected BasicDataSource getDataSource() throws IOException, SQLException { - BasicDataSource dataSource = new BasicDataSource(); - - dataSource.setDriverClassName(fixtureRule.getFixture().getProperty("driver")); - dataSource.setUrl(fixtureRule.getFixture().getProperty("url")); - dataSource.setUsername(fixtureRule.getFixture().getProperty("username")); - dataSource.setPassword(fixtureRule.getFixture().getProperty("password")); - dataSource.setPoolPreparedStatements(true); - dataSource.setAccessToUnderlyingConnectionAllowed(true); - dataSource.setMinIdle(1); - dataSource.setMaxActive(4); + BasicDataSource ds = new BasicDataSource(); + + ds.setDriverClassName(fixtureRule.getFixture().getProperty("driver")); + ds.setUrl(fixtureRule.getFixture().getProperty("url")); + ds.setUsername(fixtureRule.getFixture().getProperty("username")); + ds.setPassword(fixtureRule.getFixture().getProperty("password")); + ds.setPoolPreparedStatements(true); + ds.setAccessToUnderlyingConnectionAllowed(true); + ds.setMinIdle(1); + ds.setMaxActive(4); // if we cannot get a connection within 5 seconds give up - dataSource.setMaxWait(5000); + ds.setMaxWait(5000); - cleanupDatabase(dataSource); + cleanupDatabase(ds); - return dataSource; + return ds; } protected void cleanupDatabase(DataSource dataSource) throws SQLException { @@ -330,15 +332,19 @@ public void testTableSetup() throws Exception { } @Test - public void testRenameLayer() throws InterruptedException { + public void testRenameLayer() throws Exception { assertEquals(16, countTileSetsByLayerName("topp:states")); store.renameLayer("topp:states", "states_renamed"); assertEquals(0, countTileSetsByLayerName("topp:states")); assertEquals(16, countTileSetsByLayerName("states_renamed")); + // TILESET.KEY embeds the layer-name prefix; renaming must rewrite it too, otherwise + // subsequent getTileSetById lookups miss the row and getOrCreateTileSet inserts duplicates. + assertEquals(0, countTileSetKeysWithPrefix("topp:states#")); + assertEquals(16, countTileSetKeysWithPrefix("states_renamed#")); } @Test - public void testRenameLayer2() throws InterruptedException { + public void testRenameLayer2() throws Exception { final String oldLayerName = tilePageCalculator.getLayerNames().iterator().next(); final String newLayerName = "renamed_layer"; @@ -350,13 +356,17 @@ public void testRenameLayer2() throws InterruptedException { TileSet tileSet = tilePageCalculator.getTileSetsFor(oldLayerName).iterator().next(); TilePage page = new TilePage(tileSet.getId(), 0, 0, (byte) 0); - store.addHitsAndSetAccesTime(Collections.singleton(new PageStatsPayload(page))); + // await the async write so the TILEPAGE row is in place before we rename + store.addHitsAndSetAccesTime(Collections.singleton(new PageStatsPayload(page))) + .get(); store.addToQuotaAndTileCounts(tileSet, new Quota(BigInteger.valueOf(1024)), Collections.emptyList()); Quota expectedQuota = store.getUsedQuotaByLayerName(oldLayerName); assertEquals(1024L, expectedQuota.getBytes().longValue()); assertNotNull(store.getTileSetById(tileSet.getId())); + int tilePagesBefore = countTilePageTilesetIdsWithPrefix(oldLayerName + "#"); + assertTrue("expected at least one TILEPAGE row to follow the rename", tilePagesBefore > 0); store.renameLayer(oldLayerName, newLayerName); @@ -369,6 +379,12 @@ public void testRenameLayer2() throws InterruptedException { // created new layer? Quota newLayerUsedQuota = store.getUsedQuotaByLayerName(newLayerName); assertEquals(expectedQuota.getBytes(), newLayerUsedQuota.getBytes()); + + // KEY column and the cascading TILEPAGE.TILESET_ID must reflect the new layer prefix + assertEquals(0, countTileSetKeysWithPrefix(oldLayerName + "#")); + assertTrue(countTileSetKeysWithPrefix(newLayerName + "#") > 0); + assertEquals(0, countTilePageTilesetIdsWithPrefix(oldLayerName + "#")); + assertEquals(tilePagesBefore, countTilePageTilesetIdsWithPrefix(newLayerName + "#")); } @Test @@ -521,7 +537,7 @@ public void testDeleteLayer() throws InterruptedException { } @Test - public void testVisitor() throws Exception { + public void testVisitor() { Set tileSets1 = store.getTileSets(); final Set tileSets2 = new HashSet<>(); store.accept((tileSet, quotaStore) -> tileSets2.add(tileSet)); @@ -855,6 +871,37 @@ private int countTileSetsByLayerName(String layerName) { return count; } + /** + * Counts TILESET rows whose KEY column starts with the given prefix. Used by rename tests to verify that the KEY + * column itself - not just LAYER_NAME - has been rewritten on rename. + */ + protected int countTileSetKeysWithPrefix(String prefix) throws SQLException { + return countRowsWithColumnPrefix("TILESET", "KEY", prefix); + } + + /** + * Counts TILEPAGE rows whose TILESET_ID column starts with the given prefix. Used by rename tests to verify the FK + * cascade from TILESET.KEY -> TILEPAGE.TILESET_ID worked. + */ + protected int countTilePageTilesetIdsWithPrefix(String prefix) throws SQLException { + return countRowsWithColumnPrefix("TILEPAGE", "TILESET_ID", prefix); + } + + private int countRowsWithColumnPrefix(String table, String column, String prefix) throws SQLException { + String sql = "SELECT COUNT(*) FROM " + table + " WHERE " + column + " LIKE ?"; + try (Connection cx = dataSource.getConnection(); + PreparedStatement ps = cx.prepareStatement(sql)) { + ps.setString(1, prefix + "%"); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return rs.getInt(1); + } else { + throw new IllegalStateException(); + } + } + } + } + /** Asserts the quota used by this tile set is null */ private void assertQuotaZero(TileSet tileSet) { Quota quota = store.getUsedQuotaByTileSetId(tileSet.getId()); @@ -863,7 +910,7 @@ private void assertQuotaZero(TileSet tileSet) { } /** Asserts the quota used by this tile set is null */ - private void assertQuotaZero(String layerName) throws InterruptedException { + private void assertQuotaZero(String layerName) { Quota quota = store.getUsedQuotaByLayerName(layerName); assertNotNull(quota); assertEquals(0, quota.getBytes().longValue()); diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/AbstractJDBCQuotaStoreIT.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/AbstractJDBCQuotaStoreIT.java new file mode 100644 index 000000000..34542cb39 --- /dev/null +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/AbstractJDBCQuotaStoreIT.java @@ -0,0 +1,63 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.diskquota.jdbc.tests.container; + +import java.util.Properties; +import org.geowebcache.diskquota.jdbc.JDBCQuotaStoreTest; +import org.testcontainers.containers.JdbcDatabaseContainer; + +/** + * Abstract base for {@link JDBCQuotaStoreTest} subclasses that source their JDBC connection from a Testcontainers + * {@link JdbcDatabaseContainer} instead of the legacy {@code ~/.geowebcache/.properties} file. + * + *

The whole {@link JDBCQuotaStoreTest} suite is reused as-is: the only deviation is that this base overrides the + * inner fixture rule to short-circuit the fixture-file lookup with {@link #containerFixture()}, which derives the + * connection {@link Properties} from the running container provided by {@link #getContainer()}. + * + *

Subclasses are picked up by Failsafe (their names end in {@code IT}). Surefire ignores them, so the existing + * fixture-based subclasses keep their {@code Test}-suffix convention. + */ +public abstract class AbstractJDBCQuotaStoreIT extends JDBCQuotaStoreTest { + + /** + * @return the started {@link JdbcDatabaseContainer} the IT runs against. Subclasses typically expose it as a + * {@code @ClassRule} so it spins up once per test class. + */ + protected abstract JdbcDatabaseContainer getContainer(); + + /** + * Builds the {@link Properties} the base {@code JDBCFixtureRule} expects. Sourced from the container so that no + * filesystem fixture is needed. + */ + @SuppressWarnings("PMD.CloseResource") + protected Properties containerFixture() { + JdbcDatabaseContainer container = getContainer(); + Properties fixture = new Properties(); + fixture.put("driver", container.getDriverClassName()); + fixture.put("url", container.getJdbcUrl()); + fixture.put("username", container.getUsername()); + fixture.put("password", container.getPassword()); + return fixture; + } + + @Override + protected JDBCFixtureRule makeFixtureRule() { + return new JDBCFixtureRule(getFixtureId()) { + @Override + protected Properties createOfflineFixture() { + return containerFixture(); + } + }; + } +} diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/OracleQuotaStoreIT.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/OracleQuotaStoreIT.java new file mode 100644 index 000000000..4b80fd735 --- /dev/null +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/OracleQuotaStoreIT.java @@ -0,0 +1,111 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.diskquota.jdbc.tests.container; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import javax.sql.DataSource; +import org.geowebcache.diskquota.jdbc.OracleDialect; +import org.geowebcache.diskquota.jdbc.SQLDialect; +import org.geowebcache.testcontainers.jdbc.OracleXEContainer; +import org.junit.ClassRule; +import org.junit.Ignore; +import org.testcontainers.containers.JdbcDatabaseContainer; + +/** + * Runs the full {@code JDBCQuotaStoreTest} suite against a real Oracle Express Edition via Testcontainers. + * + *

If Docker is unavailable the class is skipped cleanly through {@link OracleXEContainer#disabledWithoutDocker()}. + * + *

This is {@link Ignore @Ignore}d for now

+ * + *

Running the suite against Oracle XE 21c surfaces a longstanding gap, not specific to this CI plumbing: any + * SERIALIZABLE transaction in {@code JDBCQuotaStore} whose first read goes through TILEPAGE (or, less often, TILESET) + * fails with {@code ORA-08176: consistent read failure; + * rollback data not available}. Oracle's own diagnostic for this error names the cause: "Encountered data + * changed by an operation that does not generate rollback data: create index, direct load or discrete + * transaction." The quota store creates four indexes on TILEPAGE at startup, and Oracle XE's snapshot machinery + * cannot reconstruct a consistent read across that recent DDL within a SERIALIZABLE transaction. + * + *

The Oracle-recommended remedy is to retry the transaction so a fresh snapshot SCN is taken. Once + * {@code JDBCQuotaStore} wraps each {@code tt.execute(...)} with bounded retry on serialization failures, ORA-08176 + * will succeed on a re-attempt - exactly the pattern Oracle's diagnostics recommend. + * + *

Concretely, what we observed running this IT against {@code gvenzl/oracle-xe:21-slim-faststart}: + * + *

+ * + *

The class is kept in the suite (rather than deleted) so: + * + *

    + *
  1. The CI workflow's claim that it covers the Oracle dialect via Testcontainers stays honest: the infrastructure + * is in place; only the {@code @Ignore} flips off when the retry layer lands. + *
  2. The Oracle XE container plumbing is exercised by the workflow at least up to {@code @ClassRule} startup, so it + * doesn't bit-rot. + *
  3. The next person to look at Oracle support has a working scaffold and a clear pointer at the root cause. + *
+ */ +@Ignore("Pending the SERIALIZABLE retry layer; see class javadoc for ORA-08176 root cause.") +public class OracleQuotaStoreIT extends AbstractJDBCQuotaStoreIT { + + @ClassRule + public static final OracleXEContainer ORACLE = OracleXEContainer.latest().disabledWithoutDocker(); + + @Override + protected SQLDialect getDialect() { + return new OracleDialect(); + } + + @Override + protected String getFixtureId() { + return "oracle-testcontainer"; + } + + @Override + protected JdbcDatabaseContainer getContainer() { + return ORACLE; + } + + /** + * Oracle requires {@code CASCADE CONSTRAINTS}, not just {@code CASCADE}, to drop tables with dependents; the base + * cleanup uses the standard SQL form which silently fails on Oracle. + */ + @Override + protected void cleanupDatabase(DataSource dataSource) throws SQLException { + try (Connection cx = dataSource.getConnection(); + Statement st = cx.createStatement()) { + try { + st.execute("DROP TABLE TILEPAGE CASCADE CONSTRAINTS"); + } catch (Exception e) { + // fine, table may not exist + } + try { + st.execute("DROP TABLE TILESET CASCADE CONSTRAINTS"); + } catch (Exception e) { + // fine too + } + } + } +} diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/PostgreSQLForeignKeyMigrationIT.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/PostgreSQLForeignKeyMigrationIT.java new file mode 100644 index 000000000..e81edc219 --- /dev/null +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/PostgreSQLForeignKeyMigrationIT.java @@ -0,0 +1,65 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.diskquota.jdbc.tests.container; + +import javax.sql.DataSource; +import org.apache.commons.dbcp.BasicDataSource; +import org.geowebcache.diskquota.jdbc.AbstractForeignKeyMigrationTest; +import org.geowebcache.diskquota.jdbc.PostgreSQLDialect; +import org.geowebcache.diskquota.jdbc.SQLDialect; +import org.geowebcache.testcontainers.jdbc.PostgresContainer; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; + +/** + * Integration test that runs {@link AbstractForeignKeyMigrationTest} against a real PostgreSQL spun up via + * Testcontainers. Validates that the FK-upgrade path works against the JDBC driver's + * {@code DatabaseMetaData.getImportedKeys} semantics on PostgreSQL, not just HSQL. + */ +public class PostgreSQLForeignKeyMigrationIT extends AbstractForeignKeyMigrationTest { + + @ClassRule + public static final PostgresContainer POSTGRES = PostgresContainer.latest().disabledWithoutDocker(); + + private BasicDataSource dataSource; + + @Before + public void setUpDataSource() throws Exception { + dataSource = new BasicDataSource(); + dataSource.setDriverClassName(POSTGRES.getDriverClassName()); + dataSource.setUrl(POSTGRES.getJdbcUrl()); + dataSource.setUsername(POSTGRES.getUsername()); + dataSource.setPassword(POSTGRES.getPassword()); + recreateLegacySchema(); + } + + @After + public void tearDown() throws Exception { + if (dataSource != null) { + dataSource.close(); + dataSource = null; + } + } + + @Override + protected SQLDialect dialect() { + return new PostgreSQLDialect(); + } + + @Override + protected DataSource dataSource() { + return dataSource; + } +} diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/PostgreSQLQuotaStoreIT.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/PostgreSQLQuotaStoreIT.java new file mode 100644 index 000000000..8e9069ecf --- /dev/null +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/tests/container/PostgreSQLQuotaStoreIT.java @@ -0,0 +1,47 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.diskquota.jdbc.tests.container; + +import org.geowebcache.diskquota.jdbc.PostgreSQLDialect; +import org.geowebcache.diskquota.jdbc.SQLDialect; +import org.geowebcache.testcontainers.jdbc.PostgresContainer; +import org.junit.ClassRule; +import org.testcontainers.containers.JdbcDatabaseContainer; + +/** + * Runs the full {@code JDBCQuotaStoreTest} suite against a real PostgreSQL via Testcontainers. + * + *

Failsafe picks this up via the {@code *IT} naming convention. If Docker is unavailable the class is skipped + * cleanly through {@link PostgresContainer#disabledWithoutDocker()}. + */ +public class PostgreSQLQuotaStoreIT extends AbstractJDBCQuotaStoreIT { + + @ClassRule + public static final PostgresContainer POSTGRES = PostgresContainer.latest().disabledWithoutDocker(); + + @Override + protected SQLDialect getDialect() { + return new PostgreSQLDialect(); + } + + @Override + protected String getFixtureId() { + return "postgresql-testcontainer"; + } + + @Override + protected JdbcDatabaseContainer getContainer() { + return POSTGRES; + } +} diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/testcontainers/jdbc/OracleXEContainer.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/testcontainers/jdbc/OracleXEContainer.java new file mode 100644 index 000000000..c460009b6 --- /dev/null +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/testcontainers/jdbc/OracleXEContainer.java @@ -0,0 +1,84 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.testcontainers.jdbc; + +import static org.junit.Assume.assumeTrue; + +import org.junit.Assume; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.OracleContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers container for Oracle Express + * Edition, used by the JDBC disk quota store integration tests. + * + *

Backed by the community {@code gvenzl/oracle-xe:21-slim-faststart} image, which trims the official Oracle XE + * distribution to a CI-friendly size (~1.4 GB) and starts in under a minute. + * + *

Usage as a {@code @ClassRule}: + * + *

+ * 
+ *   @ClassRule public static OracleXEContainer oracle = OracleXEContainer.latest().disabledWithoutDocker();
+ * 
+ * 
+ * + *

Mirrors the {@link PostgresContainer} / AzuriteContainer pattern: the {@link #disabledWithoutDocker() + * disabledWithoutDocker} flag arranges for the test class to be {@link Assume assumed} away cleanly when no Docker + * environment is available, instead of erroring at container start. + */ +public class OracleXEContainer extends OracleContainer { + + private static final DockerImageName LATEST_IMAGE = + DockerImageName.parse("gvenzl/oracle-xe:21-slim-faststart").asCompatibleSubstituteFor("gvenzl/oracle-xe"); + + private boolean disabledWithoutDocker; + + public OracleXEContainer(DockerImageName imageName) { + super(imageName); + } + + /** @return a container running {@code gvenzl/oracle-xe:21-slim-faststart} with the testcontainers defaults */ + public static OracleXEContainer latest() { + return new OracleXEContainer(LATEST_IMAGE); + } + + /** + * Skips tests using this container if no Docker environment is available, instead of failing them. + * + *

Same effect as JUnit 5's {@code @Testcontainers(disabledWithoutDocker = true)}. + */ + public OracleXEContainer disabledWithoutDocker() { + this.disabledWithoutDocker = true; + return this; + } + + /** + * Overrides {@link OracleContainer#apply} to apply the Docker-availability {@link Assume assumption} before the + * container is started, so that the test class is skipped cleanly when Docker is unavailable. + */ + @Override + @SuppressWarnings("deprecation") + public Statement apply(Statement base, Description description) { + if (disabledWithoutDocker) { + assumeTrue( + "Docker environment unavailable, ignoring " + description.getDisplayName(), + DockerClientFactory.instance().isDockerAvailable()); + } + return super.apply(base, description); + } +} diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/testcontainers/jdbc/PostgresContainer.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/testcontainers/jdbc/PostgresContainer.java new file mode 100644 index 000000000..e1e5b0030 --- /dev/null +++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/testcontainers/jdbc/PostgresContainer.java @@ -0,0 +1,80 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.testcontainers.jdbc; + +import static org.junit.Assume.assumeTrue; + +import org.junit.Assume; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers container for PostgreSQL, used by the JDBC disk quota + * store integration tests. + * + *

Usage as a {@code @ClassRule}: + * + *

+ * 
+ *   @ClassRule public static PostgresContainer postgres = PostgresContainer.latest().disabledWithoutDocker();
+ * 
+ * 
+ * + *

Mirrors the {@code AzuriteContainer} pattern used elsewhere in this repository: the + * {@link #disabledWithoutDocker() disabledWithoutDocker} flag arranges for the test class to be {@link Assume assumed} + * away cleanly when no Docker environment is available, instead of erroring at container start. + */ +public class PostgresContainer extends PostgreSQLContainer { + + private static final DockerImageName LATEST_IMAGE = DockerImageName.parse("postgres:16-alpine"); + + private boolean disabledWithoutDocker; + + public PostgresContainer(DockerImageName imageName) { + super(imageName); + } + + /** @return a container running {@code postgres:16-alpine} with default {@code test}/{@code test} credentials */ + public static PostgresContainer latest() { + return new PostgresContainer(LATEST_IMAGE); + } + + /** + * Skips tests using this container if no Docker environment is available, instead of failing them. + * + *

Same effect as JUnit 5's {@code @Testcontainers(disabledWithoutDocker = true)}. + */ + public PostgresContainer disabledWithoutDocker() { + this.disabledWithoutDocker = true; + return this; + } + + /** + * Overrides {@link PostgreSQLContainer#apply} to apply the Docker-availability {@link Assume assumption} before the + * container is started, so that the test class is skipped cleanly when Docker is unavailable. + */ + @Override + @SuppressWarnings("deprecation") + public Statement apply(Statement base, Description description) { + if (disabledWithoutDocker) { + assumeTrue( + "Docker environment unavailable, ignoring " + description.getDisplayName(), + DockerClientFactory.instance().isDockerAvailable()); + } + return super.apply(base, description); + } +}