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,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 );
0 commit comments