Skip to content

Commit 89886fd

Browse files
committed
diskquota/jdbc: retry SERIALIZABLE aborts (#1527)
Concurrent writers hitting the same TILESET or TILEPAGE row produced serialization aborts that escaped JDBCQuotaStore and reached QueuedQuotaUpdatesConsumer, which logs at FINE and drops the aggregated batch, silently losing pending quota updates and letting the on-disk ledger drift out of sync. SERIALIZABLE isolation is kept as-is; the missing piece was the application-level retry that SERIALIZABLE assumes. Wrap every JDBCQuotaStore transaction in a bounded-retry helper whose predicate recognises serialization aborts from Postgres SSI, HSQL (SQLState class "40"), and Oracle (vendor codes 8176/8177). Oracle additionally gets a DEFERRABLE INITIALLY DEFERRED TILEPAGE -> TILESET foreign key, the matching migrate-on-upgrade path, and a PL/SQL renameLayer so TILESET.KEY and TILEPAGE.TILESET_ID rewrite atomically at commit. Verified end-to-end against PostgreSQL and Oracle XE testcontainers. on-behalf-of: @camptocamp <info@camptocamp.com>
1 parent 5d1c9e5 commit 89886fd

12 files changed

Lines changed: 919 additions & 144 deletions

geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStore.java

Lines changed: 158 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import java.util.concurrent.ExecutorService;
3030
import java.util.concurrent.Executors;
3131
import java.util.concurrent.Future;
32+
import java.util.concurrent.ThreadLocalRandom;
33+
import java.util.function.Consumer;
3234
import java.util.logging.Level;
3335
import java.util.logging.Logger;
3436
import javax.sql.DataSource;
@@ -51,6 +53,7 @@
5153
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
5254
import org.springframework.transaction.TransactionStatus;
5355
import org.springframework.transaction.support.TransactionCallback;
56+
import org.springframework.transaction.support.TransactionSynchronizationManager;
5457
import org.springframework.transaction.support.TransactionTemplate;
5558

5659
/**
@@ -86,6 +89,30 @@ public class JDBCQuotaStore implements QuotaStore {
8689
/** Max number of attempts we do to insert/update page stats in race-free mode */
8790
int maxLoops = 100;
8891

92+
/**
93+
* Max attempts when a {@code SERIALIZABLE} transaction aborts due to a concurrency conflict
94+
* ({@link PessimisticLockingFailureException}).
95+
*
96+
* <p>Postgres SSI and Oracle ORA-08176 abort transactions that the application is expected to retry; this counter
97+
* bounds how many times {@link #executeWithRetry(TransactionCallback)} re-attempts before giving up.
98+
*/
99+
int maxTransactionAttempts = 10;
100+
101+
/**
102+
* Initial backoff between transaction retries, in milliseconds. Doubles on each retry up to
103+
* {@link #MAX_TRANSACTION_BACKOFF_MS}, with full jitter added to spread concurrent retriers.
104+
*/
105+
long initialTransactionBackoffMs = 10L;
106+
107+
/** Cap on the exponential backoff between transaction retries, in milliseconds. */
108+
private static final long MAX_TRANSACTION_BACKOFF_MS = 500L;
109+
110+
/** Oracle ORA-08176: consistent read failure; rollback data not available. */
111+
private static final int ORA_08176 = 8176;
112+
113+
/** Oracle ORA-08177: can't serialize access for this transaction. */
114+
private static final int ORA_08177 = 8177;
115+
89116
/** The executor used for asynch requests */
90117
ExecutorService executor;
91118

@@ -159,10 +186,12 @@ public void initialize() {
159186
throw new IllegalStateException(
160187
"Please provide both the sql dialect and the data " + "source before calling inizialize");
161188
}
162-
tt.executeWithoutResult(status -> {
163-
164-
// setup the tables if necessary
165-
dialect.initializeTables(schema, jt);
189+
// DDL outside any wrapping transaction: most engines auto-commit DDL, but Oracle in
190+
// particular needs the post-DDL SCN to settle before any SERIALIZABLE transaction
191+
// starts reading - otherwise the first SELECT inside the wrapped block crosses
192+
// recently-created indexes and aborts with ORA-08176.
193+
dialect.initializeTables(schema, jt);
194+
executeWithRetry(status -> {
166195

167196
// get the existing table names
168197
List<String> existingLayers = jt.query(dialect.getAllLayersQuery(schema), (rs, rowNum) -> rs.getString(1));
@@ -196,7 +225,7 @@ public void createLayer(String layerName) throws InterruptedException {
196225
}
197226

198227
private void createLayerInternal(final String layerName) {
199-
tt.executeWithoutResult(status -> {
228+
executeWithRetry(status -> {
200229
Set<TileSet> layerTileSets;
201230
if (!GLOBAL_QUOTA_NAME.equals(layerName)) {
202231
layerTileSets = calculator.getTileSetsFor(layerName);
@@ -276,14 +305,14 @@ private Quota nonNullQuota(Quota optionalQuota) {
276305

277306
@Override
278307
public void deleteLayer(final String layerName) {
279-
tt.executeWithoutResult(status -> {
308+
executeWithRetry(status -> {
280309
deleteLayerInternal(layerName);
281310
});
282311
}
283312

284313
@Override
285314
public void deleteGridSubset(final String layerName, final String gridSetId) {
286-
tt.executeWithoutResult(status -> {
315+
executeWithRetry(status -> {
287316
// get the disk quota used by the layer gridset
288317
Quota quota = getUsedQuotaByLayerGridset(layerName, gridSetId);
289318
// we will subtracting the current disk quota value
@@ -305,7 +334,7 @@ public void deleteGridSubset(final String layerName, final String gridSetId) {
305334

306335
public void deleteLayerInternal(final String layerName) {
307336
getUsedQuotaByLayerName(layerName);
308-
tt.executeWithoutResult(status -> {
337+
executeWithRetry(status -> {
309338
// update the global quota
310339
Quota quota = getUsedQuotaByLayerName(layerName);
311340
quota.setBytes(quota.getBytes().negate());
@@ -324,7 +353,7 @@ public void deleteLayerInternal(final String layerName) {
324353

325354
@Override
326355
public void renameLayer(final String oldLayerName, final String newLayerName) throws InterruptedException {
327-
tt.executeWithoutResult(status -> {
356+
executeWithRetry(status -> {
328357
String sql = dialect.getRenameLayerStatement(schema, "oldName", "newName");
329358
Map<String, Object> params = new HashMap<>();
330359
params.put("oldName", oldLayerName);
@@ -429,7 +458,7 @@ public TilePageCalculator getTilePageCalculator() {
429458
public void addToQuotaAndTileCounts(
430459
final TileSet tileSet, final Quota quotaDiff, final Collection<PageStatsPayload> tileCountDiffs)
431460
throws InterruptedException {
432-
tt.executeWithoutResult(status -> {
461+
executeWithRetry(status -> {
433462
getOrCreateTileSet(tileSet);
434463
updateQuotas(tileSet, quotaDiff);
435464

@@ -609,7 +638,7 @@ private PageStats getPageStats(String pageStatsKey) {
609638
@Override
610639
@SuppressWarnings("unchecked")
611640
public Future<List<PageStats>> addHitsAndSetAccesTime(final Collection<PageStatsPayload> statsUpdates) {
612-
return executor.submit(() -> (List<PageStats>) tt.execute(new QuotaStoreCallback(statsUpdates)));
641+
return executor.submit(() -> (List<PageStats>) executeWithRetry(new QuotaStoreCallback(statsUpdates)));
613642
}
614643

615644
@Override
@@ -651,7 +680,7 @@ private TilePage getSinglePage(Set<String> layerNames, boolean leastFrequentlyUs
651680

652681
@Override
653682
public PageStats setTruncated(final TilePage page) throws InterruptedException {
654-
return (PageStats) tt.execute((TransactionCallback<Object>) status -> {
683+
return (PageStats) executeWithRetry((TransactionCallback<Object>) status -> {
655684
if (log.isLoggable(Level.FINE)) {
656685
log.info("Truncating page " + page);
657686
}
@@ -693,6 +722,122 @@ public void close() throws Exception {
693722
jt = null;
694723
}
695724

725+
/**
726+
* Runs the given action in a SERIALIZABLE transaction, retrying with bounded exponential backoff if the underlying
727+
* database aborts the transaction due to a concurrency conflict.
728+
*
729+
* <p>Postgres SSI ({@code SQLSTATE 40001}, translated by Spring to {@link PessimisticLockingFailureException}) and
730+
* Oracle's analogous serialization failures are documented as retryable: SERIALIZABLE is defined in terms of
731+
* application-level retry on abort. Without this layer, the queued-update consumer thread silently swallows the
732+
* abort, dropping the batch of pending quota updates and letting the ledger drift out of sync with disk.
733+
*
734+
* <p>Non-concurrency exceptions are not retried. Other {@link ConcurrencyFailureException} subclasses thrown by
735+
* inner SQL-level loops (their own race-handling exhaustion) are also not retried here; those loops have already
736+
* done their work and re-attempting at the transaction level would only amplify cost.
737+
*
738+
* <p>When invoked from a method that is already inside a wrapping transaction (e.g. {@link #createLayerInternal}
739+
* called from {@link #initialize}), the retry loop is skipped and the exception is allowed to propagate. Spring's
740+
* {@code PROPAGATION_REQUIRED} reuses the outer transaction, so retrying here would just re-fail against the same
741+
* stale snapshot - the only retry that can recover is the outer one, which will start a fresh transaction.
742+
*/
743+
private <T> T executeWithRetry(TransactionCallback<T> action) {
744+
if (TransactionSynchronizationManager.isActualTransactionActive()) {
745+
// Nested call: let aborts bubble up to the outer retry, which can take a fresh snapshot.
746+
return tt.execute(action);
747+
}
748+
long backoff = initialTransactionBackoffMs;
749+
for (int attempt = 1; ; attempt++) {
750+
try {
751+
return tt.execute(action);
752+
} catch (DataAccessException e) {
753+
if (!isTransactionAbort(e)) {
754+
throw e;
755+
}
756+
if (attempt >= maxTransactionAttempts) {
757+
log.log(
758+
Level.WARNING,
759+
"DiskQuota transaction failed after " + attempt + " attempts: " + e.getMessage(),
760+
e);
761+
throw e;
762+
}
763+
long sleep = backoff + ThreadLocalRandom.current().nextLong(backoff);
764+
try {
765+
Thread.sleep(sleep);
766+
} catch (InterruptedException ie) {
767+
Thread.currentThread().interrupt();
768+
throw e;
769+
}
770+
if (log.isLoggable(Level.FINE)) {
771+
log.fine("DiskQuota transaction conflict on attempt "
772+
+ attempt
773+
+ "/"
774+
+ maxTransactionAttempts
775+
+ ", retrying after "
776+
+ sleep
777+
+ "ms: "
778+
+ e.getMessage());
779+
}
780+
backoff = Math.min(backoff * 2, MAX_TRANSACTION_BACKOFF_MS);
781+
}
782+
}
783+
}
784+
785+
/**
786+
* Void variant of {@link #executeWithRetry(TransactionCallback)} for the majority of call sites that don't return a
787+
* value from the transaction. The {@link TransactionStatus} is passed through so callers can flag the transaction
788+
* for rollback explicitly if needed.
789+
*/
790+
private void executeWithRetry(Consumer<TransactionStatus> action) {
791+
executeWithRetry((TransactionCallback<Void>) status -> {
792+
action.accept(status);
793+
return null;
794+
});
795+
}
796+
797+
/**
798+
* Returns {@code true} if any throwable in the cause chain is a concurrency-driven transaction abort that the
799+
* application is expected to retry. {@link org.geowebcache.diskquota.jdbc.SimpleJdbcTemplate SimpleJdbcTemplate}
800+
* wraps Spring's translated exceptions in {@link ParametricDataAccessException}, so the actual abort type is
801+
* typically a cause, not the top-level throwable.
802+
*
803+
* <p>Recognizes three families:
804+
*
805+
* <ul>
806+
* <li>Spring's {@link PessimisticLockingFailureException} (covers Postgres SSI aborts; Spring translates SQLSTATE
807+
* 40001 to {@link org.springframework.dao.CannotAcquireLockException}).
808+
* <li>Any {@link SQLException} whose {@code SQLState} class is {@code "40"} (transaction rollback, including
809+
* serialization failures and deadlocks). Catches HSQL's {@link java.sql.SQLTransactionRollbackException},
810+
* which Spring translates to a bare {@link ConcurrencyFailureException} rather than to one of the
811+
* pessimistic-locking subclasses.
812+
* <li>Oracle vendor error codes that are explicit serialization-retry signals: ORA-08176 ({@code consistent read
813+
* failure; rollback data not available}) and ORA-08177 ({@code can't serialize access for this transaction}).
814+
* Spring's translator routes 08177 to the now-deprecated {@code CannotSerializeTransactionException} (a
815+
* sibling of {@code PessimisticLockingFailureException}, not a subclass) and leaves 08176 uncategorized;
816+
* matching by JDBC vendor code keeps the dialect-specific knowledge local to this predicate.
817+
* </ul>
818+
*/
819+
private static boolean isTransactionAbort(Throwable t) {
820+
for (Throwable cause = t; cause != null; cause = cause.getCause()) {
821+
if (cause instanceof PessimisticLockingFailureException) {
822+
return true;
823+
}
824+
if (cause instanceof SQLException sqlException) {
825+
String sqlState = sqlException.getSQLState();
826+
if (sqlState != null && sqlState.startsWith("40")) {
827+
return true;
828+
}
829+
if (isRetryableOracleCode(sqlException.getErrorCode())) {
830+
return true;
831+
}
832+
}
833+
}
834+
return false;
835+
}
836+
837+
private static boolean isRetryableOracleCode(int errorCode) {
838+
return errorCode == ORA_08176 || errorCode == ORA_08177;
839+
}
840+
696841
/**
697842
* Maps a BigDecimal column into a Quota object
698843
*
@@ -752,7 +897,7 @@ public TilePage mapRow(ResultSet rs, int rowNum) throws SQLException {
752897

753898
@Override
754899
public void deleteParameters(final String layerName, final String parametersId) {
755-
tt.executeWithoutResult(status -> {
900+
executeWithRetry(status -> {
756901
// first gather the disk quota used by the gridset, and update the global
757902
// quota
758903
Quota quota = getUsedQuotaByParametersId(parametersId);

geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
*/
1414
package org.geowebcache.diskquota.jdbc;
1515

16+
import java.sql.DatabaseMetaData;
17+
import java.sql.ResultSet;
18+
import java.sql.SQLException;
1619
import java.util.List;
1720

1821
/**
@@ -60,7 +63,8 @@ BYTES NUMBER(%d) DEFAULT 0 NOT NULL
6063
"""
6164
CREATE TABLE ${schema}TILEPAGE (
6265
KEY VARCHAR(%d) PRIMARY KEY,
63-
TILESET_ID VARCHAR(%d) REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE,
66+
TILESET_ID VARCHAR(%d) REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE
67+
DEFERRABLE INITIALLY DEFERRED,
6468
PAGE_Z SMALLINT,
6569
PAGE_X INTEGER,
6670
PAGE_Y INTEGER,
@@ -84,37 +88,53 @@ protected void addEmtpyTableReference(StringBuilder sb) {
8488
}
8589

8690
/**
87-
* No-op: Oracle does not support {@code ON UPDATE CASCADE} on foreign keys, so there is nothing portable to
88-
* migrate. Companion to {@link #getRenameLayerStatement(String, String, String)}, which preserves the legacy
89-
* LAYER_NAME-only behavior on this dialect.
91+
* Oracle does not support {@code ON UPDATE CASCADE} on foreign keys, so its TILEPAGE -> TILESET FK is migrated to
92+
* {@code DEFERRABLE INITIALLY DEFERRED} instead. Deferring the FK check to commit time both lets
93+
* {@link #getRenameLayerStatement(String, String, String) renaming a layer} rewrite TILESET.KEY (and its dependent
94+
* TILEPAGE.TILESET_ID rows) atomically, and removes the snapshot read against TILESET that fires on every plain
95+
* INSERT INTO TILEPAGE - the primary trigger for ORA-08176 under SERIALIZABLE.
9096
*/
9197
@Override
92-
public void migrateForeignKeys(String schema, SimpleJdbcTemplate template) {
93-
// intentional no-op
98+
protected boolean tilepageFkIsMigrated(ResultSet rs) throws SQLException {
99+
return rs.getShort("DEFERRABILITY") == DatabaseMetaData.importedKeyInitiallyDeferred;
100+
}
101+
102+
@Override
103+
protected String tilepageFkAddSql(String prefixedTilepageName, String prefix) {
104+
return """
105+
ALTER TABLE %s ADD FOREIGN KEY (TILESET_ID)
106+
REFERENCES %sTILESET(KEY)
107+
ON DELETE CASCADE
108+
DEFERRABLE INITIALLY DEFERRED
109+
"""
110+
.formatted(prefixedTilepageName, prefix);
94111
}
95112

96113
/**
97-
* Oracle does not support {@code ON UPDATE CASCADE} on foreign keys, so the {@code TILEPAGE.TILESET_ID -> TILESET
98-
* .KEY} FK declared above only cascades on delete. As a result this dialect cannot safely rewrite {@code TILESET
99-
* .KEY} during a rename without first dealing with the dangling {@code TILEPAGE} rows.
114+
* Oracle does not support {@code ON UPDATE CASCADE} on foreign keys, so the rename cannot rely on the FK to
115+
* propagate the new {@code TILESET.KEY} prefix to {@code TILEPAGE.TILESET_ID}. Instead, this returns a PL/SQL
116+
* anonymous block that rewrites both columns within a single transaction; the {@code TILEPAGE -> TILESET} FK is
117+
* declared {@code DEFERRABLE INITIALLY DEFERRED} so the constraint is verified once at commit, with both updates
118+
* already in place.
100119
*
101-
* <p>For now Oracle keeps the legacy behavior of only updating {@code LAYER_NAME}; lookups by id against the
102-
* renamed layer will continue to miss the row and cause {@code getOrCreateTileSet} to insert duplicates. Fixing
103-
* this on Oracle (e.g. via {@code DEFERRABLE INITIALLY DEFERRED} constraints, or by disabling the FK around the
104-
* rename) is tracked separately.
120+
* <p>The block uses Oracle's {@code SUBSTR}/{@code INSTR}/{@code ||} idiom rather than SQL-standard
121+
* {@code SUBSTRING ... FROM POSITION(...)} which Oracle does not provide.
105122
*/
106123
@Override
107124
public String getRenameLayerStatement(String schema, String oldLayerName, String newLayerName) {
108-
StringBuilder sb = new StringBuilder("UPDATE ");
109-
if (schema != null) {
110-
sb.append(schema).append(".");
111-
}
112-
sb.append("TILESET SET LAYER_NAME = :")
113-
.append(newLayerName)
114-
.append(" WHERE LAYER_NAME = :")
115-
.append(oldLayerName);
116-
117-
return sb.toString();
125+
String prefix = schema == null ? "" : schema + ".";
126+
return """
127+
BEGIN
128+
UPDATE %sTILESET
129+
SET KEY = :%s || SUBSTR(KEY, INSTR(KEY, '#')),
130+
LAYER_NAME = :%s
131+
WHERE LAYER_NAME = :%s;
132+
UPDATE %sTILEPAGE
133+
SET TILESET_ID = :%s || SUBSTR(TILESET_ID, INSTR(TILESET_ID, '#'))
134+
WHERE TILESET_ID LIKE :%s || '#%%';
135+
END;
136+
"""
137+
.formatted(prefix, newLayerName, newLayerName, oldLayerName, prefix, newLayerName, oldLayerName);
118138
}
119139

120140
@Override

0 commit comments

Comments
 (0)