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 @@
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 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 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 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 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/ 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()}.
+ *
+ * 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:
+ *
+ * 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}:
+ *
+ * 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}:
+ *
+ * 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 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);
+ }
+}
This is {@link Ignore @Ignore}d for now
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+@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.
+ *
+ *
+ *
+ *
+ *
+ * @ClassRule public static OracleXEContainer oracle = OracleXEContainer.latest().disabledWithoutDocker();
+ *
+ *
+ *
+ *
+ *
+ * @ClassRule public static PostgresContainer postgres = PostgresContainer.latest().disabledWithoutDocker();
+ *
+ *