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 @@
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 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/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.
+ *
+ *
+ *
+ *
+ *
+ * @ClassRule public static OracleXEContainer oracle = OracleXEContainer.latest().disabledWithoutDocker();
+ *
+ *
+ *
+ *
+ *
+ * @ClassRule public static PostgresContainer postgres = PostgresContainer.latest().disabledWithoutDocker();
+ *
+ *