Skip to content

Commit 245fd63

Browse files
groldanaaime
authored andcommitted
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 e33416a commit 245fd63

13 files changed

Lines changed: 811 additions & 170 deletions

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

Lines changed: 112 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,20 @@ 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+
/** Max attempts in {@link #executeWithRetry(TransactionCallback)} before propagating the abort. */
93+
int maxTransactionAttempts = 10;
94+
95+
/** Initial backoff between transaction retries, in milliseconds; doubles each retry, with full jitter. */
96+
long initialTransactionBackoffMs = 10L;
97+
98+
private static final long MAX_TRANSACTION_BACKOFF_MS = 500L;
99+
100+
/** Oracle ORA-08176: consistent read failure; rollback data not available. */
101+
private static final int ORA_08176 = 8176;
102+
103+
/** Oracle ORA-08177: can't serialize access for this transaction. */
104+
private static final int ORA_08177 = 8177;
105+
89106
/** The executor used for asynch requests */
90107
ExecutorService executor;
91108

@@ -159,10 +176,10 @@ public void initialize() {
159176
throw new IllegalStateException(
160177
"Please provide both the sql dialect and the data " + "source before calling inizialize");
161178
}
162-
tt.executeWithoutResult(status -> {
163-
164-
// setup the tables if necessary
165-
dialect.initializeTables(schema, jt);
179+
// DDL must run outside the wrapping transaction: Oracle auto-commits it and a SERIALIZABLE
180+
// read across the just-created indexes would abort with ORA-08176 on the first SELECT.
181+
dialect.initializeTables(schema, jt);
182+
executeWithRetry(status -> {
166183

167184
// get the existing table names
168185
List<String> existingLayers = jt.query(dialect.getAllLayersQuery(schema), (rs, rowNum) -> rs.getString(1));
@@ -196,7 +213,7 @@ public void createLayer(String layerName) throws InterruptedException {
196213
}
197214

198215
private void createLayerInternal(final String layerName) {
199-
tt.executeWithoutResult(status -> {
216+
executeWithRetry(status -> {
200217
Set<TileSet> layerTileSets;
201218
if (!GLOBAL_QUOTA_NAME.equals(layerName)) {
202219
layerTileSets = calculator.getTileSetsFor(layerName);
@@ -276,14 +293,14 @@ private Quota nonNullQuota(Quota optionalQuota) {
276293

277294
@Override
278295
public void deleteLayer(final String layerName) {
279-
tt.executeWithoutResult(status -> {
296+
executeWithRetry(status -> {
280297
deleteLayerInternal(layerName);
281298
});
282299
}
283300

284301
@Override
285302
public void deleteGridSubset(final String layerName, final String gridSetId) {
286-
tt.executeWithoutResult(status -> {
303+
executeWithRetry(status -> {
287304
// get the disk quota used by the layer gridset
288305
Quota quota = getUsedQuotaByLayerGridset(layerName, gridSetId);
289306
// we will subtracting the current disk quota value
@@ -305,7 +322,7 @@ public void deleteGridSubset(final String layerName, final String gridSetId) {
305322

306323
public void deleteLayerInternal(final String layerName) {
307324
getUsedQuotaByLayerName(layerName);
308-
tt.executeWithoutResult(status -> {
325+
executeWithRetry(status -> {
309326
// update the global quota
310327
Quota quota = getUsedQuotaByLayerName(layerName);
311328
quota.setBytes(quota.getBytes().negate());
@@ -324,7 +341,7 @@ public void deleteLayerInternal(final String layerName) {
324341

325342
@Override
326343
public void renameLayer(final String oldLayerName, final String newLayerName) throws InterruptedException {
327-
tt.executeWithoutResult(status -> {
344+
executeWithRetry(status -> {
328345
String sql = dialect.getRenameLayerStatement(schema, "oldName", "newName");
329346
Map<String, Object> params = new HashMap<>();
330347
params.put("oldName", oldLayerName);
@@ -429,7 +446,7 @@ public TilePageCalculator getTilePageCalculator() {
429446
public void addToQuotaAndTileCounts(
430447
final TileSet tileSet, final Quota quotaDiff, final Collection<PageStatsPayload> tileCountDiffs)
431448
throws InterruptedException {
432-
tt.executeWithoutResult(status -> {
449+
executeWithRetry(status -> {
433450
getOrCreateTileSet(tileSet);
434451
updateQuotas(tileSet, quotaDiff);
435452

@@ -609,7 +626,7 @@ private PageStats getPageStats(String pageStatsKey) {
609626
@Override
610627
@SuppressWarnings("unchecked")
611628
public Future<List<PageStats>> addHitsAndSetAccesTime(final Collection<PageStatsPayload> statsUpdates) {
612-
return executor.submit(() -> (List<PageStats>) tt.execute(new QuotaStoreCallback(statsUpdates)));
629+
return executor.submit(() -> (List<PageStats>) executeWithRetry(new QuotaStoreCallback(statsUpdates)));
613630
}
614631

615632
@Override
@@ -651,7 +668,7 @@ private TilePage getSinglePage(Set<String> layerNames, boolean leastFrequentlyUs
651668

652669
@Override
653670
public PageStats setTruncated(final TilePage page) throws InterruptedException {
654-
return (PageStats) tt.execute((TransactionCallback<Object>) status -> {
671+
return (PageStats) executeWithRetry((TransactionCallback<Object>) status -> {
655672
if (log.isLoggable(Level.FINE)) {
656673
log.info("Truncating page " + page);
657674
}
@@ -693,6 +710,88 @@ public void close() throws Exception {
693710
jt = null;
694711
}
695712

713+
/**
714+
* Runs {@code action} in a SERIALIZABLE transaction, retrying on concurrency aborts with bounded exponential
715+
* backoff. If the call is already nested inside an active transaction the retry loop is skipped: Spring's
716+
* {@code PROPAGATION_REQUIRED} would reuse the same stale snapshot, so only the outermost call can recover.
717+
*/
718+
private <T> T executeWithRetry(TransactionCallback<T> action) {
719+
if (TransactionSynchronizationManager.isActualTransactionActive()) {
720+
return tt.execute(action);
721+
}
722+
long backoff = initialTransactionBackoffMs;
723+
for (int attempt = 1; ; attempt++) {
724+
try {
725+
return tt.execute(action);
726+
} catch (DataAccessException e) {
727+
if (!isTransactionAbort(e)) {
728+
throw e;
729+
}
730+
if (attempt >= maxTransactionAttempts) {
731+
log.log(
732+
Level.WARNING,
733+
"DiskQuota transaction failed after " + attempt + " attempts: " + e.getMessage(),
734+
e);
735+
throw e;
736+
}
737+
long sleep = backoff + ThreadLocalRandom.current().nextLong(backoff);
738+
try {
739+
Thread.sleep(sleep);
740+
} catch (InterruptedException ie) {
741+
Thread.currentThread().interrupt();
742+
throw e;
743+
}
744+
if (log.isLoggable(Level.FINE)) {
745+
log.fine("DiskQuota transaction conflict on attempt "
746+
+ attempt
747+
+ "/"
748+
+ maxTransactionAttempts
749+
+ ", retrying after "
750+
+ sleep
751+
+ "ms: "
752+
+ e.getMessage());
753+
}
754+
backoff = Math.min(backoff * 2, MAX_TRANSACTION_BACKOFF_MS);
755+
}
756+
}
757+
}
758+
759+
/** Void variant of {@link #executeWithRetry(TransactionCallback)}. */
760+
private void executeWithRetry(Consumer<TransactionStatus> action) {
761+
executeWithRetry((TransactionCallback<Void>) status -> {
762+
action.accept(status);
763+
return null;
764+
});
765+
}
766+
767+
/**
768+
* Walks the cause chain looking for a retryable concurrency abort. Spring's translator alone is not enough:
769+
* SQLSTATE class {@code 40} catches HSQL's bare {@link ConcurrencyFailureException}, and Oracle vendor codes 8176
770+
* and 8177 are needed because Spring leaves 8176 uncategorized and routes 8177 to a deprecated sibling of
771+
* {@link PessimisticLockingFailureException}.
772+
*/
773+
private static boolean isTransactionAbort(Throwable t) {
774+
for (Throwable cause = t; cause != null; cause = cause.getCause()) {
775+
if (cause instanceof PessimisticLockingFailureException) {
776+
return true;
777+
}
778+
if (cause instanceof SQLException sqlException) {
779+
String sqlState = sqlException.getSQLState();
780+
if (sqlState != null && sqlState.startsWith("40")) {
781+
return true;
782+
}
783+
if (isRetryableOracleCode(sqlException.getErrorCode())) {
784+
return true;
785+
}
786+
}
787+
}
788+
return false;
789+
}
790+
791+
private static boolean isRetryableOracleCode(int errorCode) {
792+
return errorCode == ORA_08176 || errorCode == ORA_08177;
793+
}
794+
696795
/**
697796
* Maps a BigDecimal column into a Quota object
698797
*
@@ -752,7 +851,7 @@ public TilePage mapRow(ResultSet rs, int rowNum) throws SQLException {
752851

753852
@Override
754853
public void deleteParameters(final String layerName, final String parametersId) {
755-
tt.executeWithoutResult(status -> {
854+
executeWithRetry(status -> {
756855
// first gather the disk quota used by the gridset, and update the global
757856
// quota
758857
Quota quota = getUsedQuotaByParametersId(parametersId);

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

Lines changed: 37 additions & 24 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,46 @@ 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}, so the FK is migrated to {@code DEFERRABLE INITIALLY DEFERRED}
92+
* instead. Deferring the check to commit time also drops the per-INSERT snapshot read on TILESET that triggers
93+
* ORA-08176 under SERIALIZABLE.
9094
*/
9195
@Override
92-
public void migrateForeignKeys(String schema, SimpleJdbcTemplate template) {
93-
// intentional no-op
96+
protected boolean tilepageFkIsMigrated(ResultSet rs) throws SQLException {
97+
return rs.getShort("DEFERRABILITY") == DatabaseMetaData.importedKeyInitiallyDeferred;
98+
}
99+
100+
@Override
101+
protected String tilepageFkAddSql(String prefixedTilepageName, String prefix) {
102+
return """
103+
ALTER TABLE %s ADD FOREIGN KEY (TILESET_ID)
104+
REFERENCES %sTILESET(KEY)
105+
ON DELETE CASCADE
106+
DEFERRABLE INITIALLY DEFERRED
107+
"""
108+
.formatted(prefixedTilepageName, prefix);
94109
}
95110

96111
/**
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.
100-
*
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.
112+
* PL/SQL anonymous block that rewrites TILESET.KEY and TILEPAGE.TILESET_ID together; the deferred FK is checked
113+
* once at commit with both updates in place. Oracle has no {@code ON UPDATE CASCADE}, hence the manual rewrite, and
114+
* no SQL-standard {@code SUBSTRING ... FROM POSITION(...)}, hence {@code SUBSTR}/{@code INSTR}.
105115
*/
106116
@Override
107117
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();
118+
String prefix = schema == null ? "" : schema + ".";
119+
return """
120+
BEGIN
121+
UPDATE %sTILESET
122+
SET KEY = :%s || SUBSTR(KEY, INSTR(KEY, '#')),
123+
LAYER_NAME = :%s
124+
WHERE LAYER_NAME = :%s;
125+
UPDATE %sTILEPAGE
126+
SET TILESET_ID = :%s || SUBSTR(TILESET_ID, INSTR(TILESET_ID, '#'))
127+
WHERE INSTR(TILESET_ID, :%s || '#') = 1;
128+
END;
129+
"""
130+
.formatted(prefix, newLayerName, newLayerName, oldLayerName, prefix, newLayerName, oldLayerName);
118131
}
119132

120133
@Override

0 commit comments

Comments
 (0)