2929import java .util .concurrent .ExecutorService ;
3030import java .util .concurrent .Executors ;
3131import java .util .concurrent .Future ;
32+ import java .util .concurrent .ThreadLocalRandom ;
33+ import java .util .function .Consumer ;
3234import java .util .logging .Level ;
3335import java .util .logging .Logger ;
3436import javax .sql .DataSource ;
5153import org .springframework .jdbc .datasource .DataSourceTransactionManager ;
5254import org .springframework .transaction .TransactionStatus ;
5355import org .springframework .transaction .support .TransactionCallback ;
56+ import org .springframework .transaction .support .TransactionSynchronizationManager ;
5457import 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 );
0 commit comments