From bd982291416107ee2b504458ebe17696fffa8dd4 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 3 Dec 2025 17:39:43 +0000 Subject: [PATCH 01/29] JAVA-5950 - Update Transactions Convenient API with exponential backoff on retries --- .../mongodb/internal/ExponentialBackoff.java | 174 +++++++++++++++ .../internal/ExponentialBackoffTest.java | 205 ++++++++++++++++++ .../client/internal/ClientSessionImpl.java | 38 +++- .../client/WithTransactionProseTest.java | 39 ++++ 4 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java create mode 100644 driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java diff --git a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java new file mode 100644 index 00000000000..518286319ad --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java @@ -0,0 +1,174 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.internal; + +import com.mongodb.annotations.NotThreadSafe; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * Implements exponential backoff with jitter for retry scenarios. + * Formula: delayMS = jitter * min(maxBackoffMs, baseBackoffMs * growthFactor^retryCount) + * where jitter is random value [0, 1). + * + *

This class provides factory methods for common use cases: + *

+ */ +@NotThreadSafe +public final class ExponentialBackoff { + // Transaction retry constants (per spec) + private static final double TRANSACTION_BASE_BACKOFF_MS = 5.0; + private static final double TRANSACTION_MAX_BACKOFF_MS = 500.0; + private static final double TRANSACTION_BACKOFF_GROWTH = 1.5; + + // Command retry constants (per spec) + private static final double COMMAND_BASE_BACKOFF_MS = 100.0; + private static final double COMMAND_MAX_BACKOFF_MS = 10000.0; + private static final double COMMAND_BACKOFF_GROWTH = 2.0; + + private final double baseBackoffMs; + private final double maxBackoffMs; + private final double growthFactor; + private int retryCount = 0; + + /** + * Creates an exponential backoff instance with specified parameters. + * + * @param baseBackoffMs Initial backoff in milliseconds + * @param maxBackoffMs Maximum backoff cap in milliseconds + * @param growthFactor Exponential growth factor (e.g., 1.5 or 2.0) + */ + public ExponentialBackoff(final double baseBackoffMs, final double maxBackoffMs, final double growthFactor) { + this.baseBackoffMs = baseBackoffMs; + this.maxBackoffMs = maxBackoffMs; + this.growthFactor = growthFactor; + } + + /** + * Creates a backoff instance configured for withTransaction retries. + * Uses: 5ms base, 500ms max, 1.5 growth factor. + * + * @return ExponentialBackoff configured for transaction retries + */ + public static ExponentialBackoff forTransactionRetry() { + return new ExponentialBackoff( + TRANSACTION_BASE_BACKOFF_MS, + TRANSACTION_MAX_BACKOFF_MS, + TRANSACTION_BACKOFF_GROWTH + ); + } + + /** + * Creates a backoff instance configured for command retries during overload. + * Uses: 100ms base, 10000ms max, 2.0 growth factor. + * + * @return ExponentialBackoff configured for command retries + */ + public static ExponentialBackoff forCommandRetry() { + return new ExponentialBackoff( + COMMAND_BASE_BACKOFF_MS, + COMMAND_MAX_BACKOFF_MS, + COMMAND_BACKOFF_GROWTH + ); + } + + /** + * Calculate next backoff delay with jitter. + * + * @return delay in milliseconds + */ + public long calculateDelayMs() { + double jitter = ThreadLocalRandom.current().nextDouble(); + double exponentialBackoff = baseBackoffMs * Math.pow(growthFactor, retryCount); + double cappedBackoff = Math.min(exponentialBackoff, maxBackoffMs); + retryCount++; + return Math.round(jitter * cappedBackoff); + } + + /** + * Apply backoff delay by sleeping current thread. + * + * @throws InterruptedException if thread is interrupted during sleep + */ + public void applyBackoff() throws InterruptedException { + long delayMs = calculateDelayMs(); + if (delayMs > 0) { + Thread.sleep(delayMs); + } + } + + /** + * Check if applying backoff would exceed the retry time limit. + * @param startTimeMs start time of retry attempts + * @param maxRetryTimeMs maximum retry time allowed + * @return true if backoff would exceed limit, false otherwise + */ +// public boolean wouldExceedTimeLimit(final long startTimeMs, final long maxRetryTimeMs) { +// long elapsedMs = ClientSessionClock.INSTANCE.now() - startTimeMs; +// // Peek at next delay without incrementing counter +// double exponentialBackoff = baseBackoffMs * Math.pow(growthFactor, retryCount); +// double cappedBackoff = Math.min(exponentialBackoff, maxBackoffMs); +// long maxPossibleDelay = Math.round(cappedBackoff); // worst case with jitter=1 +// return elapsedMs + maxPossibleDelay > maxRetryTimeMs; +// } + + /** + * Reset retry counter for new sequence of retries. + */ + public void reset() { + retryCount = 0; + } + + /** + * Get current retry count for testing. + * + * @return current retry count + */ + public int getRetryCount() { + return retryCount; + } + + /** + * Get the base backoff in milliseconds. + * + * @return base backoff + */ + public double getBaseBackoffMs() { + return baseBackoffMs; + } + + /** + * Get the maximum backoff in milliseconds. + * + * @return maximum backoff + */ + public double getMaxBackoffMs() { + return maxBackoffMs; + } + + /** + * Get the growth factor. + * + * @return growth factor + */ + public double getGrowthFactor() { + return growthFactor; + } +} diff --git a/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java b/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java new file mode 100644 index 00000000000..bfee96e67fb --- /dev/null +++ b/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java @@ -0,0 +1,205 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.internal; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ExponentialBackoffTest { + + @Test + void testTransactionRetryBackoff() { + ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + + // Verify configuration + assertEquals(5.0, backoff.getBaseBackoffMs()); + assertEquals(500.0, backoff.getMaxBackoffMs()); + assertEquals(1.5, backoff.getGrowthFactor()); + + // First retry (i=0): delay = jitter * min(5 * 1.5^0, 500) = jitter * 5 + // Since jitter is random [0,1), the delay should be between 0 and 5ms + long delay1 = backoff.calculateDelayMs(); + assertTrue(delay1 >= 0 && delay1 <= 5, "First delay should be 0-5ms, got: " + delay1); + + // Second retry (i=1): delay = jitter * min(5 * 1.5^1, 500) = jitter * 7.5 + long delay2 = backoff.calculateDelayMs(); + assertTrue(delay2 >= 0 && delay2 <= 8, "Second delay should be 0-8ms, got: " + delay2); + + // Third retry (i=2): delay = jitter * min(5 * 1.5^2, 500) = jitter * 11.25 + long delay3 = backoff.calculateDelayMs(); + assertTrue(delay3 >= 0 && delay3 <= 12, "Third delay should be 0-12ms, got: " + delay3); + + // Verify the retry count is incrementing properly + assertEquals(3, backoff.getRetryCount()); + } + + @Test + void testTransactionRetryBackoffRespectsMaximum() { + ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + + // Advance to a high retry count where backoff would exceed 500ms without capping + for (int i = 0; i < 20; i++) { + backoff.calculateDelayMs(); + } + + // Even at high retry counts, delay should never exceed 500ms + for (int i = 0; i < 5; i++) { + long delay = backoff.calculateDelayMs(); + assertTrue(delay >= 0 && delay <= 500, "Delay should be capped at 500ms, got: " + delay); + } + } + + @Test + void testTransactionRetryBackoffSequenceWithExpectedValues() { + // Test that the backoff sequence follows the expected pattern with growth factor 1.5 + // Expected sequence (without jitter): 5, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, ... + // With jitter, actual values will be between 0 and these maxima + + ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + + double[] expectedMaxValues = {5.0, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, 85.4296875, + 128.14453125, 192.21679688, 288.32519531, 432.48779297, 500.0}; + + for (int i = 0; i < expectedMaxValues.length; i++) { + long delay = backoff.calculateDelayMs(); + assertTrue(delay >= 0 && delay <= Math.round(expectedMaxValues[i]), + String.format("Retry %d: delay should be 0-%d ms, got: %d", i, Math.round(expectedMaxValues[i]), delay)); + } + } + + @Test + void testCommandRetryBackoff() { + ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); + + // Verify configuration + assertEquals(100.0, backoff.getBaseBackoffMs()); + assertEquals(10000.0, backoff.getMaxBackoffMs()); + assertEquals(2.0, backoff.getGrowthFactor()); + + // Test sequence with growth factor 2.0 + // Expected max delays: 100, 200, 400, 800, 1600, 3200, 6400, 10000 (capped) + long delay1 = backoff.calculateDelayMs(); + assertTrue(delay1 >= 0 && delay1 <= 100, "First delay should be 0-100ms, got: " + delay1); + + long delay2 = backoff.calculateDelayMs(); + assertTrue(delay2 >= 0 && delay2 <= 200, "Second delay should be 0-200ms, got: " + delay2); + + long delay3 = backoff.calculateDelayMs(); + assertTrue(delay3 >= 0 && delay3 <= 400, "Third delay should be 0-400ms, got: " + delay3); + + long delay4 = backoff.calculateDelayMs(); + assertTrue(delay4 >= 0 && delay4 <= 800, "Fourth delay should be 0-800ms, got: " + delay4); + + long delay5 = backoff.calculateDelayMs(); + assertTrue(delay5 >= 0 && delay5 <= 1600, "Fifth delay should be 0-1600ms, got: " + delay5); + } + + @Test + void testCommandRetryBackoffRespectsMaximum() { + ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); + + // Advance to where exponential would exceed 10000ms + for (int i = 0; i < 10; i++) { + backoff.calculateDelayMs(); + } + + // Even at high retry counts, delay should never exceed 10000ms + for (int i = 0; i < 5; i++) { + long delay = backoff.calculateDelayMs(); + assertTrue(delay >= 0 && delay <= 10000, "Delay should be capped at 10000ms, got: " + delay); + } + } + + @Test + void testCustomBackoff() { + // Test with custom parameters + ExponentialBackoff backoff = new ExponentialBackoff(50.0, 2000.0, 1.8); + + assertEquals(50.0, backoff.getBaseBackoffMs()); + assertEquals(2000.0, backoff.getMaxBackoffMs()); + assertEquals(1.8, backoff.getGrowthFactor()); + + // First delay: 0-50ms + long delay1 = backoff.calculateDelayMs(); + assertTrue(delay1 >= 0 && delay1 <= 50, "First delay should be 0-50ms, got: " + delay1); + + // Second delay: 0-90ms (50 * 1.8) + long delay2 = backoff.calculateDelayMs(); + assertTrue(delay2 >= 0 && delay2 <= 90, "Second delay should be 0-90ms, got: " + delay2); + } + + @Test + void testReset() { + ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + + // Perform some retries + backoff.calculateDelayMs(); + backoff.calculateDelayMs(); + assertEquals(2, backoff.getRetryCount()); + + // Reset and verify counter is back to 0 + backoff.reset(); + assertEquals(0, backoff.getRetryCount()); + + // First delay after reset should be in the initial range again + long delay = backoff.calculateDelayMs(); + assertTrue(delay >= 0 && delay <= 5, "First delay after reset should be 0-5ms, got: " + delay); + } + +// @Test +// void testWouldExceedTimeLimitTransactionRetry() { +// ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); +// long startTime = ClientSessionClock.INSTANCE.now(); +// +// // Initially, should not exceed time limit +// assertFalse(backoff.wouldExceedTimeLimit(startTime, 120000)); +// +// // With very little time remaining (4ms), first backoff (up to 5ms) would exceed +// long nearLimitTime = startTime - 119996; // 4ms remaining +// assertTrue(backoff.wouldExceedTimeLimit(nearLimitTime, 120000)); +// } + +// @Test +// void testWouldExceedTimeLimitCommandRetry() { +// ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); +// long startTime = ClientSessionClock.INSTANCE.now(); +// +// // Initially, should not exceed time limit +// assertFalse(backoff.wouldExceedTimeLimit(startTime, 10000)); +// +// // With 99ms remaining, first backoff (up to 100ms) would exceed +// long nearLimitTime = startTime - 9901; // 99ms remaining +// assertTrue(backoff.wouldExceedTimeLimit(nearLimitTime, 10000)); +// } + + @Test + void testCommandRetrySequenceMatchesSpec() { + // Test that command retry follows spec: 100ms * 2^i capped at 10000ms + ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); + + double[] expectedMaxValues = {100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0, 6400.0, 10000.0, 10000.0}; + + for (int i = 0; i < expectedMaxValues.length; i++) { + long delay = backoff.calculateDelayMs(); + double expectedMax = expectedMaxValues[i]; + assertTrue(delay >= 0 && delay <= Math.round(expectedMax), + String.format("Retry %d: delay should be 0-%d ms, got: %d", i, Math.round(expectedMax), delay)); + } + } +} diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index aa1414dce5d..63e1d68165b 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -22,12 +22,14 @@ import com.mongodb.MongoExecutionTimeoutException; import com.mongodb.MongoInternalException; import com.mongodb.MongoOperationTimeoutException; +import com.mongodb.MongoTimeoutException; import com.mongodb.ReadConcern; import com.mongodb.TransactionOptions; import com.mongodb.WriteConcern; import com.mongodb.client.ClientSession; import com.mongodb.client.TransactionBody; import com.mongodb.internal.TimeoutContext; +import com.mongodb.internal.ExponentialBackoff; import com.mongodb.internal.operation.AbortTransactionOperation; import com.mongodb.internal.operation.CommitTransactionOperation; import com.mongodb.internal.operation.OperationHelper; @@ -251,10 +253,35 @@ public T withTransaction(final TransactionBody transactionBody, final Tra notNull("transactionBody", transactionBody); long startTime = ClientSessionClock.INSTANCE.now(); TimeoutContext withTransactionTimeoutContext = createTimeoutContext(options); + // Use CSOT timeout if set, otherwise default to MAX_RETRY_TIME_LIMIT_MS + Long timeoutMS = withTransactionTimeoutContext.getTimeoutSettings().getTimeoutMS(); + long maxRetryTimeMS = timeoutMS != null ? timeoutMS : MAX_RETRY_TIME_LIMIT_MS; + ExponentialBackoff transactionBackoff = null; + boolean isRetry = false; try { outer: while (true) { + // Apply exponential backoff before retrying transaction + if (isRetry) { + // Check if we've exceeded the retry time limit BEFORE applying backoff + if (ClientSessionClock.INSTANCE.now() - startTime >= maxRetryTimeMS) { + throw withTransactionTimeoutContext.hasTimeoutMS() + ? new MongoOperationTimeoutException("Transaction retry exceeded the timeout limit") + : new MongoTimeoutException("Transaction retry time limit exceeded"); + + } + if (transactionBackoff == null) { + transactionBackoff = ExponentialBackoff.forTransactionRetry(); + } + try { + transactionBackoff.applyBackoff(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MongoClientException("Transaction retry interrupted", e); + } + } + isRetry = true; T retVal; try { startTransaction(options, withTransactionTimeoutContext.copyTimeoutContext()); @@ -269,7 +296,7 @@ public T withTransaction(final TransactionBody transactionBody, final Tra if (e instanceof MongoException && !(e instanceof MongoOperationTimeoutException)) { MongoException exceptionToHandle = OperationHelper.unwrap((MongoException) e); if (exceptionToHandle.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL) - && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { + && ClientSessionClock.INSTANCE.now() - startTime < maxRetryTimeMS) { if (transactionSpan != null) { transactionSpan.spanFinalizing(false); } @@ -280,13 +307,20 @@ public T withTransaction(final TransactionBody transactionBody, final Tra } if (transactionState == TransactionState.IN) { while (true) { + // Check if we've exceeded the retry time limit + if (ClientSessionClock.INSTANCE.now() - startTime >= maxRetryTimeMS) { + throw hasTimeoutMS(withTransactionTimeoutContext) + ? new MongoOperationTimeoutException("Transaction commit retry time limit exceeded") + : new MongoTimeoutException("Transaction commit retry time limit exceeded"); + } + try { commitTransaction(false); break; } catch (MongoException e) { clearTransactionContextOnError(e); if (!(e instanceof MongoOperationTimeoutException) - && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { + && ClientSessionClock.INSTANCE.now() - startTime < maxRetryTimeMS) { applyMajorityWriteConcernToTransactionOptions(); if (!(e instanceof MongoExecutionTimeoutException) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 1afbf61565e..406cee6c9a6 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static com.mongodb.ClusterFixture.TIMEOUT; import static com.mongodb.ClusterFixture.isDiscoverableReplicaSet; @@ -203,6 +204,44 @@ public void testTimeoutMSAndLegacySettings() { } } + // + // Test that exponential backoff is applied when retrying transactions + // Backoff uses growth factor of 1.5 as per spec + // + @Test + public void testExponentialBackoffOnTransientError() { + // Configure failpoint to simulate transient errors + MongoDatabase failPointAdminDb = client.getDatabase("admin"); + failPointAdminDb.runCommand( + Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 3}, " + + "'data': {'failCommands': ['insert'], 'errorCode': 112, " + + "'errorLabels': ['TransientTransactionError']}}")); + + try (ClientSession session = client.startSession()) { + long startTime = System.currentTimeMillis(); + + // Track retry count + AtomicInteger retryCount = new AtomicInteger(0); + + session.withTransaction(() -> { + retryCount.incrementAndGet(); // Count the attempt before the operation that might fail + collection.insertOne(session, Document.parse("{ _id : 'backoff-test' }")); + return retryCount; + }); + + long elapsedTime = System.currentTimeMillis() - startTime; + + // With backoff (growth factor 1.5), we expect at least some delay between retries + // Expected delays (without jitter): 5ms, 7.5ms, 11.25ms + // With jitter, actual delays will be between 0 and these values + // 3 retries with backoff should take at least a few milliseconds + assertTrue(elapsedTime > 5, "Expected backoff delays to be applied"); + assertEquals(4, retryCount.get(), "Expected 1 initial attempt + 3 retries"); + } finally { + failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); + } + } + private boolean canRunTests() { return isSharded() || isDiscoverableReplicaSet(); } From 72cd714b2851c1401cf20a59db8d25b4e94944e6 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 13:27:38 +0000 Subject: [PATCH 02/29] Simplifying test, clean up. --- .../com/mongodb/client/internal/ClientSessionImpl.java | 1 - .../com/mongodb/client/WithTransactionProseTest.java | 7 ------- 2 files changed, 8 deletions(-) diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index 63e1d68165b..855c21e275e 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -269,7 +269,6 @@ public T withTransaction(final TransactionBody transactionBody, final Tra throw withTransactionTimeoutContext.hasTimeoutMS() ? new MongoOperationTimeoutException("Transaction retry exceeded the timeout limit") : new MongoTimeoutException("Transaction retry time limit exceeded"); - } if (transactionBackoff == null) { transactionBackoff = ExponentialBackoff.forTransactionRetry(); diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 406cee6c9a6..01768a43671 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -229,13 +229,6 @@ public void testExponentialBackoffOnTransientError() { return retryCount; }); - long elapsedTime = System.currentTimeMillis() - startTime; - - // With backoff (growth factor 1.5), we expect at least some delay between retries - // Expected delays (without jitter): 5ms, 7.5ms, 11.25ms - // With jitter, actual delays will be between 0 and these values - // 3 retries with backoff should take at least a few milliseconds - assertTrue(elapsedTime > 5, "Expected backoff delays to be applied"); assertEquals(4, retryCount.get(), "Expected 1 initial attempt + 3 retries"); } finally { failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); From 573c86c5cd60b52cfeee81ef4d35e0e3b5600f0c Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 14:22:57 +0000 Subject: [PATCH 03/29] Fixing test --- .../com/mongodb/client/internal/ClientSessionImpl.java | 7 ------- .../com/mongodb/client/WithTransactionProseTest.java | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index 855c21e275e..b38b1f9027d 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -306,13 +306,6 @@ public T withTransaction(final TransactionBody transactionBody, final Tra } if (transactionState == TransactionState.IN) { while (true) { - // Check if we've exceeded the retry time limit - if (ClientSessionClock.INSTANCE.now() - startTime >= maxRetryTimeMS) { - throw hasTimeoutMS(withTransactionTimeoutContext) - ? new MongoOperationTimeoutException("Transaction commit retry time limit exceeded") - : new MongoTimeoutException("Transaction commit retry time limit exceeded"); - } - try { commitTransaction(false); break; diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 01768a43671..c8a27cce9cc 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -147,7 +147,7 @@ public void testRetryTimeoutEnforcedUnknownTransactionCommit() { public void testRetryTimeoutEnforcedTransientTransactionErrorOnCommit() { MongoDatabase failPointAdminDb = client.getDatabase("admin"); failPointAdminDb.runCommand( - Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 2}, " + Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 1}, " + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 251, 'codeName': 'NoSuchTransaction', " + "'errmsg': 'Transaction 0 has been aborted', 'closeConnection': false}}")); From ecf9b4d5edbe6e2297d7c263fa5041500d157e31 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 15:32:31 +0000 Subject: [PATCH 04/29] Update driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/com/mongodb/client/internal/ClientSessionImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index b38b1f9027d..a65d3340886 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -267,8 +267,8 @@ public T withTransaction(final TransactionBody transactionBody, final Tra // Check if we've exceeded the retry time limit BEFORE applying backoff if (ClientSessionClock.INSTANCE.now() - startTime >= maxRetryTimeMS) { throw withTransactionTimeoutContext.hasTimeoutMS() - ? new MongoOperationTimeoutException("Transaction retry exceeded the timeout limit") - : new MongoTimeoutException("Transaction retry time limit exceeded"); + ? new MongoOperationTimeoutException("Transaction retry time limit of " + maxRetryTimeMS + "ms exceeded") + : new MongoTimeoutException("Transaction retry time limit of " + maxRetryTimeMS + "ms exceeded"); } if (transactionBackoff == null) { transactionBackoff = ExponentialBackoff.forTransactionRetry(); From 68dbece86a29156a897e4c4074fcbefbf90e4143 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 15:43:23 +0000 Subject: [PATCH 05/29] retrigger checks From 7cfc4c4297b1ef5332635ead3ae8b91f9e240cc6 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 17:01:23 +0000 Subject: [PATCH 06/29] retrigger checks From cd96a49e9f5f02598932ffc331c35bfe5ac0585b Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 17:16:33 +0000 Subject: [PATCH 07/29] retrigger checks From 82dc8b186fc5b80e3a0faea4301c293435e7d5e4 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 17:20:32 +0000 Subject: [PATCH 08/29] retrigger checks From 558bfadbe8514534108a4b76cd47b4764ba69bd5 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 17:25:53 +0000 Subject: [PATCH 09/29] test cleanup --- .../functional/com/mongodb/client/WithTransactionProseTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index c8a27cce9cc..78d884ca14c 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -124,7 +124,7 @@ public void testRetryTimeoutEnforcedUnknownTransactionCommit() { try (ClientSession session = client.startSession()) { ClientSessionClock.INSTANCE.setTime(START_TIME_MS); - session.withTransaction((TransactionBody) () -> { + session.withTransaction(() -> { ClientSessionClock.INSTANCE.setTime(ERROR_GENERATING_INTERVAL); collection.insertOne(session, new Document("_id", 2)); return null; From fbe881ef5c6cbfbcfac2b6c39ca39a48f3f3a602 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 22:53:30 +0000 Subject: [PATCH 10/29] retrigger checks From d89c7140541f907b1250004fa8e25c118c757df9 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 9 Dec 2025 23:16:34 +0000 Subject: [PATCH 11/29] Test cleanup --- .../com/mongodb/client/WithTransactionProseTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 78d884ca14c..e06f3c82908 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -119,7 +119,7 @@ public void testRetryTimeoutEnforcedTransientTransactionError() { public void testRetryTimeoutEnforcedUnknownTransactionCommit() { MongoDatabase failPointAdminDb = client.getDatabase("admin"); failPointAdminDb.runCommand( - Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 2}, " + Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 1}, " + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 91, 'closeConnection': false}}")); try (ClientSession session = client.startSession()) { @@ -153,7 +153,7 @@ public void testRetryTimeoutEnforcedTransientTransactionErrorOnCommit() { try (ClientSession session = client.startSession()) { ClientSessionClock.INSTANCE.setTime(START_TIME_MS); - session.withTransaction((TransactionBody) () -> { + session.withTransaction(() -> { ClientSessionClock.INSTANCE.setTime(ERROR_GENERATING_INTERVAL); collection.insertOne(session, Document.parse("{ _id : 1 }")); return null; From 2eccff0bd88056e2a0a729e5f799a1324a0a7649 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 10 Dec 2025 00:06:08 +0000 Subject: [PATCH 12/29] retrigger checks From c406ae712d125b44d57766d92d2568546a360a06 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 10 Dec 2025 14:31:11 +0000 Subject: [PATCH 13/29] Update the implementation according to the spec --- .../client/internal/ClientSessionImpl.java | 21 ++++++++++++------- .../client/WithTransactionProseTest.java | 10 ++++----- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index a65d3340886..59ef120a08b 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -22,7 +22,6 @@ import com.mongodb.MongoExecutionTimeoutException; import com.mongodb.MongoInternalException; import com.mongodb.MongoOperationTimeoutException; -import com.mongodb.MongoTimeoutException; import com.mongodb.ReadConcern; import com.mongodb.TransactionOptions; import com.mongodb.WriteConcern; @@ -258,23 +257,27 @@ public T withTransaction(final TransactionBody transactionBody, final Tra long maxRetryTimeMS = timeoutMS != null ? timeoutMS : MAX_RETRY_TIME_LIMIT_MS; ExponentialBackoff transactionBackoff = null; boolean isRetry = false; + MongoException lastError = null; try { outer: while (true) { // Apply exponential backoff before retrying transaction if (isRetry) { - // Check if we've exceeded the retry time limit BEFORE applying backoff - if (ClientSessionClock.INSTANCE.now() - startTime >= maxRetryTimeMS) { - throw withTransactionTimeoutContext.hasTimeoutMS() - ? new MongoOperationTimeoutException("Transaction retry time limit of " + maxRetryTimeMS + "ms exceeded") - : new MongoTimeoutException("Transaction retry time limit of " + maxRetryTimeMS + "ms exceeded"); - } if (transactionBackoff == null) { transactionBackoff = ExponentialBackoff.forTransactionRetry(); } + // Calculate backoff delay and check if it would exceed timeout + long backoffMs = transactionBackoff.calculateDelayMs(); + if (ClientSessionClock.INSTANCE.now() - startTime + backoffMs >= maxRetryTimeMS) { + // Throw the last error as per spec + // lastError is always set here since we only retry on MongoException + throw lastError; + } try { - transactionBackoff.applyBackoff(); + if (backoffMs > 0) { + Thread.sleep(backoffMs); + } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new MongoClientException("Transaction retry interrupted", e); @@ -296,6 +299,7 @@ public T withTransaction(final TransactionBody transactionBody, final Tra MongoException exceptionToHandle = OperationHelper.unwrap((MongoException) e); if (exceptionToHandle.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL) && ClientSessionClock.INSTANCE.now() - startTime < maxRetryTimeMS) { + lastError = exceptionToHandle; // Track the last error for timeout scenarios if (transactionSpan != null) { transactionSpan.spanFinalizing(false); } @@ -310,6 +314,7 @@ public T withTransaction(final TransactionBody transactionBody, final Tra commitTransaction(false); break; } catch (MongoException e) { + lastError = e; // Track the last error for timeout scenarios clearTransactionContextOnError(e); if (!(e instanceof MongoOperationTimeoutException) && ClientSessionClock.INSTANCE.now() - startTime < maxRetryTimeMS) { diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index e06f3c82908..45f28e586fe 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -119,12 +119,12 @@ public void testRetryTimeoutEnforcedTransientTransactionError() { public void testRetryTimeoutEnforcedUnknownTransactionCommit() { MongoDatabase failPointAdminDb = client.getDatabase("admin"); failPointAdminDb.runCommand( - Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 1}, " + Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 2}, " + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 91, 'closeConnection': false}}")); try (ClientSession session = client.startSession()) { ClientSessionClock.INSTANCE.setTime(START_TIME_MS); - session.withTransaction(() -> { + session.withTransaction((TransactionBody) () -> { ClientSessionClock.INSTANCE.setTime(ERROR_GENERATING_INTERVAL); collection.insertOne(session, new Document("_id", 2)); return null; @@ -147,13 +147,13 @@ public void testRetryTimeoutEnforcedUnknownTransactionCommit() { public void testRetryTimeoutEnforcedTransientTransactionErrorOnCommit() { MongoDatabase failPointAdminDb = client.getDatabase("admin"); failPointAdminDb.runCommand( - Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 1}, " + Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 2}, " + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 251, 'codeName': 'NoSuchTransaction', " + "'errmsg': 'Transaction 0 has been aborted', 'closeConnection': false}}")); try (ClientSession session = client.startSession()) { ClientSessionClock.INSTANCE.setTime(START_TIME_MS); - session.withTransaction(() -> { + session.withTransaction((TransactionBody) () -> { ClientSessionClock.INSTANCE.setTime(ERROR_GENERATING_INTERVAL); collection.insertOne(session, Document.parse("{ _id : 1 }")); return null; @@ -218,8 +218,6 @@ public void testExponentialBackoffOnTransientError() { + "'errorLabels': ['TransientTransactionError']}}")); try (ClientSession session = client.startSession()) { - long startTime = System.currentTimeMillis(); - // Track retry count AtomicInteger retryCount = new AtomicInteger(0); From a28ce984d5c72d75d6a399483e9054622d33abc4 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 10 Dec 2025 16:28:47 +0000 Subject: [PATCH 14/29] Added prose test --- .../mongodb/internal/ExponentialBackoff.java | 41 ++--- .../internal/ExponentialBackoffTest.java | 152 ++++++++++++++---- .../client/WithTransactionProseTest.java | 55 +++++++ 3 files changed, 196 insertions(+), 52 deletions(-) diff --git a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java index 518286319ad..0a01d38e271 100644 --- a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java +++ b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java @@ -19,6 +19,9 @@ import com.mongodb.annotations.NotThreadSafe; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.DoubleSupplier; + +import static com.mongodb.internal.VisibleForTesting.AccessModifier.PRIVATE; /** * Implements exponential backoff with jitter for retry scenarios. @@ -48,6 +51,9 @@ public final class ExponentialBackoff { private final double growthFactor; private int retryCount = 0; + // Test-only jitter supplier - when set, overrides ThreadLocalRandom + private static volatile DoubleSupplier testJitterSupplier = null; + /** * Creates an exponential backoff instance with specified parameters. * @@ -95,7 +101,10 @@ public static ExponentialBackoff forCommandRetry() { * @return delay in milliseconds */ public long calculateDelayMs() { - double jitter = ThreadLocalRandom.current().nextDouble(); + // Use test jitter supplier if set, otherwise use ThreadLocalRandom + double jitter = testJitterSupplier != null + ? testJitterSupplier.getAsDouble() + : ThreadLocalRandom.current().nextDouble(); double exponentialBackoff = baseBackoffMs * Math.pow(growthFactor, retryCount); double cappedBackoff = Math.min(exponentialBackoff, maxBackoffMs); retryCount++; @@ -103,31 +112,23 @@ public long calculateDelayMs() { } /** - * Apply backoff delay by sleeping current thread. + * Set a custom jitter supplier for testing purposes. + * This overrides the default ThreadLocalRandom jitter generation. * - * @throws InterruptedException if thread is interrupted during sleep + * @param supplier A DoubleSupplier that returns values in [0, 1) range, or null to use default */ - public void applyBackoff() throws InterruptedException { - long delayMs = calculateDelayMs(); - if (delayMs > 0) { - Thread.sleep(delayMs); - } + @VisibleForTesting(otherwise = PRIVATE) + public static void setTestJitterSupplier(final DoubleSupplier supplier) { + testJitterSupplier = supplier; } /** - * Check if applying backoff would exceed the retry time limit. - * @param startTimeMs start time of retry attempts - * @param maxRetryTimeMs maximum retry time allowed - * @return true if backoff would exceed limit, false otherwise + * Clear the test jitter supplier, reverting to default ThreadLocalRandom behavior. */ -// public boolean wouldExceedTimeLimit(final long startTimeMs, final long maxRetryTimeMs) { -// long elapsedMs = ClientSessionClock.INSTANCE.now() - startTimeMs; -// // Peek at next delay without incrementing counter -// double exponentialBackoff = baseBackoffMs * Math.pow(growthFactor, retryCount); -// double cappedBackoff = Math.min(exponentialBackoff, maxBackoffMs); -// long maxPossibleDelay = Math.round(cappedBackoff); // worst case with jitter=1 -// return elapsedMs + maxPossibleDelay > maxRetryTimeMs; -// } + @VisibleForTesting(otherwise = PRIVATE) + public static void clearTestJitterSupplier() { + testJitterSupplier = null; + } /** * Reset retry counter for new sequence of retries. diff --git a/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java b/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java index bfee96e67fb..84ab56a0e47 100644 --- a/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java +++ b/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java @@ -16,6 +16,7 @@ package com.mongodb.internal; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -23,6 +24,12 @@ public class ExponentialBackoffTest { + @AfterEach + void cleanup() { + // Always clear the test jitter supplier after each test to avoid test pollution + ExponentialBackoff.clearTestJitterSupplier(); + } + @Test void testTransactionRetryBackoff() { ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); @@ -73,13 +80,11 @@ void testTransactionRetryBackoffSequenceWithExpectedValues() { ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); - double[] expectedMaxValues = {5.0, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, 85.4296875, - 128.14453125, 192.21679688, 288.32519531, 432.48779297, 500.0}; + double[] expectedMaxValues = {5.0, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, 85.4296875, 128.14453125, 192.21679688, 288.32519531, 432.48779297, 500.0}; for (int i = 0; i < expectedMaxValues.length; i++) { long delay = backoff.calculateDelayMs(); - assertTrue(delay >= 0 && delay <= Math.round(expectedMaxValues[i]), - String.format("Retry %d: delay should be 0-%d ms, got: %d", i, Math.round(expectedMaxValues[i]), delay)); + assertTrue(delay >= 0 && delay <= Math.round(expectedMaxValues[i]), String.format("Retry %d: delay should be 0-%d ms, got: %d", i, Math.round(expectedMaxValues[i]), delay)); } } @@ -162,32 +167,6 @@ void testReset() { assertTrue(delay >= 0 && delay <= 5, "First delay after reset should be 0-5ms, got: " + delay); } -// @Test -// void testWouldExceedTimeLimitTransactionRetry() { -// ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); -// long startTime = ClientSessionClock.INSTANCE.now(); -// -// // Initially, should not exceed time limit -// assertFalse(backoff.wouldExceedTimeLimit(startTime, 120000)); -// -// // With very little time remaining (4ms), first backoff (up to 5ms) would exceed -// long nearLimitTime = startTime - 119996; // 4ms remaining -// assertTrue(backoff.wouldExceedTimeLimit(nearLimitTime, 120000)); -// } - -// @Test -// void testWouldExceedTimeLimitCommandRetry() { -// ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); -// long startTime = ClientSessionClock.INSTANCE.now(); -// -// // Initially, should not exceed time limit -// assertFalse(backoff.wouldExceedTimeLimit(startTime, 10000)); -// -// // With 99ms remaining, first backoff (up to 100ms) would exceed -// long nearLimitTime = startTime - 9901; // 99ms remaining -// assertTrue(backoff.wouldExceedTimeLimit(nearLimitTime, 10000)); -// } - @Test void testCommandRetrySequenceMatchesSpec() { // Test that command retry follows spec: 100ms * 2^i capped at 10000ms @@ -198,8 +177,117 @@ void testCommandRetrySequenceMatchesSpec() { for (int i = 0; i < expectedMaxValues.length; i++) { long delay = backoff.calculateDelayMs(); double expectedMax = expectedMaxValues[i]; - assertTrue(delay >= 0 && delay <= Math.round(expectedMax), - String.format("Retry %d: delay should be 0-%d ms, got: %d", i, Math.round(expectedMax), delay)); + assertTrue(delay >= 0 && delay <= Math.round(expectedMax), String.format("Retry %d: delay should be 0-%d ms, got: %d", i, Math.round(expectedMax), delay)); + } + } + + // Tests for the test jitter supplier functionality + + @Test + void testJitterSupplierWithZeroJitter() { + // Set jitter to always return 0 (no backoff) + ExponentialBackoff.setTestJitterSupplier(() -> 0.0); + + ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + + // With jitter = 0, all delays should be 0 + for (int i = 0; i < 10; i++) { + long delay = backoff.calculateDelayMs(); + assertEquals(0, delay, "With jitter=0, delay should always be 0ms"); + } + } + + @Test + void testJitterSupplierWithFullJitter() { + // Set jitter to always return 1.0 (full backoff) + ExponentialBackoff.setTestJitterSupplier(() -> 1.0); + + ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + + // Expected delays with jitter=1.0 and growth factor 1.5 + double[] expectedDelays = {5.0, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, 85.4296875, 128.14453125, 192.21679688, 288.32519531, 432.48779297, 500.0}; + + for (int i = 0; i < expectedDelays.length; i++) { + long delay = backoff.calculateDelayMs(); + long expected = Math.round(expectedDelays[i]); + assertEquals(expected, delay, String.format("Retry %d: with jitter=1.0, delay should be %dms", i, expected)); + } + } + + @Test + void testJitterSupplierWithHalfJitter() { + // Set jitter to always return 0.5 (half backoff) + ExponentialBackoff.setTestJitterSupplier(() -> 0.5); + + ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + + // Expected delays with jitter=0.5 and growth factor 1.5 + double[] expectedMaxDelays = {5.0, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, 85.4296875, 128.14453125, 192.21679688, 288.32519531, 432.48779297, 500.0}; + + for (int i = 0; i < expectedMaxDelays.length; i++) { + long delay = backoff.calculateDelayMs(); + long expected = Math.round(0.5 * expectedMaxDelays[i]); + assertEquals(expected, delay, String.format("Retry %d: with jitter=0.5, delay should be %dms", i, expected)); + } + } + + @Test + void testJitterSupplierForCommandRetry() { + // Test that custom jitter also works with command retry configuration + ExponentialBackoff.setTestJitterSupplier(() -> 1.0); + + ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); + + // Expected first few delays with jitter=1.0 and growth factor 2.0 + long[] expectedDelays = {100, 200, 400, 800, 1600, 3200, 6400, 10000}; + + for (int i = 0; i < expectedDelays.length; i++) { + long delay = backoff.calculateDelayMs(); + assertEquals(expectedDelays[i], delay, String.format("Command retry %d: with jitter=1.0, delay should be %dms", i, expectedDelays[i])); + } + } + + @Test + void testClearingJitterSupplierReturnsToRandom() { + // First set a fixed jitter + ExponentialBackoff.setTestJitterSupplier(() -> 0.0); + + ExponentialBackoff backoff1 = ExponentialBackoff.forTransactionRetry(); + long delay1 = backoff1.calculateDelayMs(); + assertEquals(0, delay1, "With jitter=0, delay should be 0ms"); + + // Clear the test jitter supplier + ExponentialBackoff.clearTestJitterSupplier(); + + // Now delays should be random again + ExponentialBackoff backoff2 = ExponentialBackoff.forTransactionRetry(); + + // Run multiple times to verify randomness (statistically very unlikely to get all zeros) + boolean foundNonZero = false; + for (int i = 0; i < 20; i++) { + long delay = backoff2.calculateDelayMs(); + assertTrue(delay >= 0 && delay <= Math.round(5.0 * Math.pow(1.5, i)), "Delay should be within expected range"); + if (delay > 0) { + foundNonZero = true; + } } + assertTrue(foundNonZero, "After clearing test jitter, should get some non-zero delays (random behavior)"); + } + + @Test + void testJitterSupplierWithCustomBackoff() { + // Test that custom jitter works with custom backoff parameters + ExponentialBackoff.setTestJitterSupplier(() -> 0.75); + + ExponentialBackoff backoff = new ExponentialBackoff(100.0, 1000.0, 2.5); + + // First delay: 0.75 * 100 = 75 + assertEquals(75, backoff.calculateDelayMs()); + + // Second delay: 0.75 * 100 * 2.5 = 0.75 * 250 = 188 (rounded) + assertEquals(188, backoff.calculateDelayMs()); + + // Third delay: 0.75 * 100 * 2.5^2 = 0.75 * 625 = 469 (rounded) + assertEquals(469, backoff.calculateDelayMs()); } } diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 45f28e586fe..43292321ead 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -22,6 +22,7 @@ import com.mongodb.TransactionOptions; import com.mongodb.client.internal.ClientSessionClock; import com.mongodb.client.model.Sorts; +import com.mongodb.internal.ExponentialBackoff; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -233,6 +234,60 @@ public void testExponentialBackoffOnTransientError() { } } + // + // Test that retries within withTransaction do not occur immediately + // This test verifies that exponential backoff is enforced during commit retries + // See: https://github.com/mongodb/specifications/blob/master/source/transactions-convenient-api/tests/README.md#retry-backoff-is-enforced + // + @Test + public void testRetryBackoffIsEnforced() { + MongoDatabase failPointAdminDb = client.getDatabase("admin"); + + // Test 1: Run with jitter = 0 (no backoff) + ExponentialBackoff.setTestJitterSupplier(() -> 0.0); + + failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 13}, " + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 251}}")); + + long noBackoffTime; + try (ClientSession session = client.startSession()) { + long startNanos = System.nanoTime(); + session.withTransaction(() -> { + collection.insertOne(session, Document.parse("{ _id : 'backoff-test-no-jitter' }")); + return null; + }); + long endNanos = System.nanoTime(); + noBackoffTime = TimeUnit.NANOSECONDS.toMillis(endNanos - startNanos); + } finally { + // Clear the test jitter supplier to avoid affecting other tests + ExponentialBackoff.clearTestJitterSupplier(); + failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); + } + + // Test 2: Run with jitter = 1 (full backoff) + ExponentialBackoff.setTestJitterSupplier(() -> 1.0); + + failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 13}, " + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 251}}")); + + long withBackoffTime; + try (ClientSession session = client.startSession()) { + long startNanos = System.nanoTime(); + session.withTransaction(() -> { + collection.insertOne(session, Document.parse("{ _id : 'backoff-test-full-jitter' }")); + return null; + }); + long endNanos = System.nanoTime(); + withBackoffTime = TimeUnit.NANOSECONDS.toMillis(endNanos - startNanos); + } finally { + ExponentialBackoff.clearTestJitterSupplier(); + failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); + } + + long expectedWithBackoffTime = noBackoffTime + 2200; // 2.2 seconds as per spec + long actualDifference = Math.abs(withBackoffTime - expectedWithBackoffTime); + + assertTrue(actualDifference < 1000, String.format("Expected withBackoffTime to be ~%dms (noBackoffTime %dms + 2200ms), " + "but got %dms. Difference: %dms (tolerance: 1000ms per spec)", expectedWithBackoffTime, noBackoffTime, withBackoffTime, actualDifference)); + } + private boolean canRunTests() { return isSharded() || isDiscoverableReplicaSet(); } From 27ddef90473bedb83310a740bf81971ce5782af4 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 10 Dec 2025 18:16:26 +0000 Subject: [PATCH 15/29] Flaky test --- .../client/AbstractClientSideOperationsTimeoutProseTest.java | 3 +++ .../com/mongodb/client/WithTransactionProseTest.java | 2 ++ 2 files changed, 5 insertions(+) diff --git a/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java index 9ce58b1654f..094ae51f5d2 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java @@ -675,6 +675,9 @@ public void test10CustomTestWithTransactionUsesASingleTimeout() { } @DisplayName("10. Convenient Transactions - Custom Test: with transaction uses a single timeout - lock") + // The timing of when the timeout check occurred relative to the retry attempts and backoff delays could vary based on system load and jitter values, sometimes allowing + // the LockTimeout error to surface before the timeout was detected. + @FlakyTest(maxAttempts = 3) @Test public void test10CustomTestWithTransactionUsesASingleTimeoutWithLock() { assumeTrue(serverVersionAtLeast(4, 4)); diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 43292321ead..bcd52025ac2 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -25,6 +25,7 @@ import com.mongodb.internal.ExponentialBackoff; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.concurrent.TimeUnit; @@ -239,6 +240,7 @@ public void testExponentialBackoffOnTransientError() { // This test verifies that exponential backoff is enforced during commit retries // See: https://github.com/mongodb/specifications/blob/master/source/transactions-convenient-api/tests/README.md#retry-backoff-is-enforced // + @DisplayName("Retry Backoff is Enforced") @Test public void testRetryBackoffIsEnforced() { MongoDatabase failPointAdminDb = client.getDatabase("admin"); From 3bef60a2bba3c1fe4363ef53b570459d087ae614 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Thu, 11 Dec 2025 11:51:59 +0000 Subject: [PATCH 16/29] Remove extra Test annotation --- .../client/AbstractClientSideOperationsTimeoutProseTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java index 094ae51f5d2..b4694e143a0 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java @@ -678,7 +678,6 @@ public void test10CustomTestWithTransactionUsesASingleTimeout() { // The timing of when the timeout check occurred relative to the retry attempts and backoff delays could vary based on system load and jitter values, sometimes allowing // the LockTimeout error to surface before the timeout was detected. @FlakyTest(maxAttempts = 3) - @Test public void test10CustomTestWithTransactionUsesASingleTimeoutWithLock() { assumeTrue(serverVersionAtLeast(4, 4)); assumeFalse(isStandalone()); From b0517012ed46e9d619b953627bcca04e2b052d75 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Sun, 14 Dec 2025 16:06:12 +0000 Subject: [PATCH 17/29] Throwing correct exception when CSOT is used --- .../mongodb/client/internal/ClientSessionImpl.java | 12 +++++++++--- ...AbstractClientSideOperationsTimeoutProseTest.java | 4 +--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index 59ef120a08b..5798f8afd80 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -270,9 +270,15 @@ public T withTransaction(final TransactionBody transactionBody, final Tra // Calculate backoff delay and check if it would exceed timeout long backoffMs = transactionBackoff.calculateDelayMs(); if (ClientSessionClock.INSTANCE.now() - startTime + backoffMs >= maxRetryTimeMS) { - // Throw the last error as per spec - // lastError is always set here since we only retry on MongoException - throw lastError; + // If CSOT is enabled (timeoutMS is set), throw MongoOperationTimeoutException + // Otherwise, throw the last error directly for backward compatibility + if (timeoutMS != null) { + throw new MongoOperationTimeoutException( + "Transaction retry timeout exceeded after " + (ClientSessionClock.INSTANCE.now() - startTime) + "ms", + lastError); + } else { + throw lastError; + } } try { if (backoffMs > 0) { diff --git a/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java index b4694e143a0..9ce58b1654f 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java @@ -675,9 +675,7 @@ public void test10CustomTestWithTransactionUsesASingleTimeout() { } @DisplayName("10. Convenient Transactions - Custom Test: with transaction uses a single timeout - lock") - // The timing of when the timeout check occurred relative to the retry attempts and backoff delays could vary based on system load and jitter values, sometimes allowing - // the LockTimeout error to surface before the timeout was detected. - @FlakyTest(maxAttempts = 3) + @Test public void test10CustomTestWithTransactionUsesASingleTimeoutWithLock() { assumeTrue(serverVersionAtLeast(4, 4)); assumeFalse(isStandalone()); From 90ec4d547bf269fcefd49f77138ce4ecc6ebf393 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Sun, 14 Dec 2025 17:51:47 +0000 Subject: [PATCH 18/29] Simplifying implementation by relying on CSOT to throw when timeout is exceeded (ex operationContext.getTimeoutContext().getReadTimeoutMS()) --- .../client/internal/ClientSessionImpl.java | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index 5798f8afd80..a68ec9bc69e 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -27,8 +27,10 @@ import com.mongodb.WriteConcern; import com.mongodb.client.ClientSession; import com.mongodb.client.TransactionBody; -import com.mongodb.internal.TimeoutContext; import com.mongodb.internal.ExponentialBackoff; +import com.mongodb.internal.TimeoutContext; +import com.mongodb.internal.observability.micrometer.TracingManager; +import com.mongodb.internal.observability.micrometer.TransactionSpan; import com.mongodb.internal.operation.AbortTransactionOperation; import com.mongodb.internal.operation.CommitTransactionOperation; import com.mongodb.internal.operation.OperationHelper; @@ -37,8 +39,6 @@ import com.mongodb.internal.operation.WriteOperation; import com.mongodb.internal.session.BaseClientSessionImpl; import com.mongodb.internal.session.ServerSessionPool; -import com.mongodb.internal.observability.micrometer.TracingManager; -import com.mongodb.internal.observability.micrometer.TransactionSpan; import com.mongodb.lang.Nullable; import static com.mongodb.MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL; @@ -252,12 +252,8 @@ public T withTransaction(final TransactionBody transactionBody, final Tra notNull("transactionBody", transactionBody); long startTime = ClientSessionClock.INSTANCE.now(); TimeoutContext withTransactionTimeoutContext = createTimeoutContext(options); - // Use CSOT timeout if set, otherwise default to MAX_RETRY_TIME_LIMIT_MS - Long timeoutMS = withTransactionTimeoutContext.getTimeoutSettings().getTimeoutMS(); - long maxRetryTimeMS = timeoutMS != null ? timeoutMS : MAX_RETRY_TIME_LIMIT_MS; ExponentialBackoff transactionBackoff = null; boolean isRetry = false; - MongoException lastError = null; try { outer: @@ -267,19 +263,7 @@ public T withTransaction(final TransactionBody transactionBody, final Tra if (transactionBackoff == null) { transactionBackoff = ExponentialBackoff.forTransactionRetry(); } - // Calculate backoff delay and check if it would exceed timeout long backoffMs = transactionBackoff.calculateDelayMs(); - if (ClientSessionClock.INSTANCE.now() - startTime + backoffMs >= maxRetryTimeMS) { - // If CSOT is enabled (timeoutMS is set), throw MongoOperationTimeoutException - // Otherwise, throw the last error directly for backward compatibility - if (timeoutMS != null) { - throw new MongoOperationTimeoutException( - "Transaction retry timeout exceeded after " + (ClientSessionClock.INSTANCE.now() - startTime) + "ms", - lastError); - } else { - throw lastError; - } - } try { if (backoffMs > 0) { Thread.sleep(backoffMs); @@ -304,8 +288,7 @@ public T withTransaction(final TransactionBody transactionBody, final Tra if (e instanceof MongoException && !(e instanceof MongoOperationTimeoutException)) { MongoException exceptionToHandle = OperationHelper.unwrap((MongoException) e); if (exceptionToHandle.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL) - && ClientSessionClock.INSTANCE.now() - startTime < maxRetryTimeMS) { - lastError = exceptionToHandle; // Track the last error for timeout scenarios + && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { if (transactionSpan != null) { transactionSpan.spanFinalizing(false); } @@ -320,10 +303,9 @@ public T withTransaction(final TransactionBody transactionBody, final Tra commitTransaction(false); break; } catch (MongoException e) { - lastError = e; // Track the last error for timeout scenarios clearTransactionContextOnError(e); if (!(e instanceof MongoOperationTimeoutException) - && ClientSessionClock.INSTANCE.now() - startTime < maxRetryTimeMS) { + && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { applyMajorityWriteConcernToTransactionOptions(); if (!(e instanceof MongoExecutionTimeoutException) From 19f129ccb90da23e2fc552bd1373036aec9c730d Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 13 Jan 2026 15:52:49 +0000 Subject: [PATCH 19/29] Fixing implementation according to spec changes in JAVA-6046 and https://github.com/mongodb/specifications/pull/1868 --- .../mongodb/internal/ExponentialBackoff.java | 16 ++++++++ .../client/internal/ClientSessionImpl.java | 39 ++++++++++++------- .../client/WithTransactionProseTest.java | 6 ++- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java index 0a01d38e271..42a9ff1d385 100644 --- a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java +++ b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java @@ -111,6 +111,22 @@ public long calculateDelayMs() { return Math.round(jitter * cappedBackoff); } + /** + * Calculate backoff delay with jitter for a specific retry count. + * This method does not modify the internal retry counter. + * + * @param retryCount the retry count to calculate delay for + * @return delay in milliseconds + */ + public long calculateDelayMs(final int retryCount) { + double jitter = testJitterSupplier != null + ? testJitterSupplier.getAsDouble() + : ThreadLocalRandom.current().nextDouble(); + double exponentialBackoff = baseBackoffMs * Math.pow(growthFactor, retryCount); + double cappedBackoff = Math.min(exponentialBackoff, maxBackoffMs); + return Math.round(jitter * cappedBackoff); + } + /** * Set a custom jitter supplier for testing purposes. * This overrides the default ThreadLocalRandom jitter generation. diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index a68ec9bc69e..ddbc0ecf205 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -252,18 +252,22 @@ public T withTransaction(final TransactionBody transactionBody, final Tra notNull("transactionBody", transactionBody); long startTime = ClientSessionClock.INSTANCE.now(); TimeoutContext withTransactionTimeoutContext = createTimeoutContext(options); - ExponentialBackoff transactionBackoff = null; - boolean isRetry = false; + ExponentialBackoff transactionBackoff = ExponentialBackoff.forTransactionRetry(); + int transactionAttempt = 0; + MongoException lastError = null; try { outer: while (true) { - // Apply exponential backoff before retrying transaction - if (isRetry) { - if (transactionBackoff == null) { - transactionBackoff = ExponentialBackoff.forTransactionRetry(); + if (transactionAttempt > 0) { + long backoffMs = transactionBackoff.calculateDelayMs(transactionAttempt - 1); + // Check if backoff would exceed timeout + if (ClientSessionClock.INSTANCE.now() + backoffMs - startTime >= MAX_RETRY_TIME_LIMIT_MS) { + if (lastError != null) { + throw lastError; + } + throw new MongoClientException("Transaction retry timeout exceeded"); } - long backoffMs = transactionBackoff.calculateDelayMs(); try { if (backoffMs > 0) { Thread.sleep(backoffMs); @@ -273,10 +277,11 @@ public T withTransaction(final TransactionBody transactionBody, final Tra throw new MongoClientException("Transaction retry interrupted", e); } } - isRetry = true; T retVal; try { startTransaction(options, withTransactionTimeoutContext.copyTimeoutContext()); + transactionAttempt++; + if (transactionSpan != null) { transactionSpan.setIsConvenientTransaction(); } @@ -285,14 +290,17 @@ public T withTransaction(final TransactionBody transactionBody, final Tra if (transactionState == TransactionState.IN) { abortTransaction(); } - if (e instanceof MongoException && !(e instanceof MongoOperationTimeoutException)) { - MongoException exceptionToHandle = OperationHelper.unwrap((MongoException) e); - if (exceptionToHandle.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL) - && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { - if (transactionSpan != null) { - transactionSpan.spanFinalizing(false); + if (e instanceof MongoException) { + lastError = (MongoException) e; // Store last error + if (!(e instanceof MongoOperationTimeoutException)) { + MongoException exceptionToHandle = OperationHelper.unwrap((MongoException) e); + if (exceptionToHandle.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL) + && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { + if (transactionSpan != null) { + transactionSpan.spanFinalizing(false); + } + continue; } - continue; } } throw e; @@ -315,6 +323,7 @@ public T withTransaction(final TransactionBody transactionBody, final Tra if (transactionSpan != null) { transactionSpan.spanFinalizing(true); } + lastError = e; continue outer; } } diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index bcd52025ac2..5d3b3b176df 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -284,10 +284,12 @@ public void testRetryBackoffIsEnforced() { failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); } - long expectedWithBackoffTime = noBackoffTime + 2200; // 2.2 seconds as per spec + long expectedWithBackoffTime = noBackoffTime + 1800; // 1.8 seconds as per spec long actualDifference = Math.abs(withBackoffTime - expectedWithBackoffTime); - assertTrue(actualDifference < 1000, String.format("Expected withBackoffTime to be ~%dms (noBackoffTime %dms + 2200ms), " + "but got %dms. Difference: %dms (tolerance: 1000ms per spec)", expectedWithBackoffTime, noBackoffTime, withBackoffTime, actualDifference)); + assertTrue(actualDifference < 1000, String.format("Expected withBackoffTime to be ~%dms (noBackoffTime %dms + 1800ms), but" + + " got %dms. Difference: %dms (tolerance: 1000ms per spec)", expectedWithBackoffTime, noBackoffTime, withBackoffTime, + actualDifference)); } private boolean canRunTests() { From a7d4ee7ff90f692252e800ebca7e1f7a8e1d2f2a Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 13 Jan 2026 23:47:24 +0000 Subject: [PATCH 20/29] Update driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java Co-authored-by: Valentin Kovalenko --- .../com/mongodb/client/WithTransactionProseTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 5d3b3b176df..50c1a69f90a 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -225,8 +225,7 @@ public void testExponentialBackoffOnTransientError() { session.withTransaction(() -> { retryCount.incrementAndGet(); // Count the attempt before the operation that might fail - collection.insertOne(session, Document.parse("{ _id : 'backoff-test' }")); - return retryCount; + return collection.insertOne(session, Document.parse("{ _id : 'backoff-test' }")); }); assertEquals(4, retryCount.get(), "Expected 1 initial attempt + 3 retries"); From 7222af92fd08d7b747a9307256559cbaca22bc0a Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 14 Jan 2026 00:00:25 +0000 Subject: [PATCH 21/29] Update driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java Co-authored-by: Valentin Kovalenko --- .../functional/com/mongodb/client/WithTransactionProseTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 50c1a69f90a..9453ff09b2c 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -211,7 +211,7 @@ public void testTimeoutMSAndLegacySettings() { // Backoff uses growth factor of 1.5 as per spec // @Test - public void testExponentialBackoffOnTransientError() { + void testExponentialBackoffOnTransientError() { // Configure failpoint to simulate transient errors MongoDatabase failPointAdminDb = client.getDatabase("admin"); failPointAdminDb.runCommand( From 5d5506b65c232d43dbd2778b9c56c3d56c8285a3 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 14 Jan 2026 00:01:09 +0000 Subject: [PATCH 22/29] Update driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java Co-authored-by: Valentin Kovalenko --- .../com/mongodb/client/WithTransactionProseTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 9453ff09b2c..9c917ac7771 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -276,8 +276,7 @@ public void testRetryBackoffIsEnforced() { collection.insertOne(session, Document.parse("{ _id : 'backoff-test-full-jitter' }")); return null; }); - long endNanos = System.nanoTime(); - withBackoffTime = TimeUnit.NANOSECONDS.toMillis(endNanos - startNanos); + withBackoffTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); } finally { ExponentialBackoff.clearTestJitterSupplier(); failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); From 028e144fd6826354d3664a6ee8d1f36d20bdf60b Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 14 Jan 2026 00:01:52 +0000 Subject: [PATCH 23/29] Update driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java Co-authored-by: Valentin Kovalenko --- .../functional/com/mongodb/client/WithTransactionProseTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 9c917ac7771..e38d99fd014 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -241,7 +241,7 @@ void testExponentialBackoffOnTransientError() { // @DisplayName("Retry Backoff is Enforced") @Test - public void testRetryBackoffIsEnforced() { + void testRetryBackoffIsEnforced() { MongoDatabase failPointAdminDb = client.getDatabase("admin"); // Test 1: Run with jitter = 0 (no backoff) From b68100d5cd007af9ff99e13c78bddbdbdbe2adcf Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 14 Jan 2026 00:03:12 +0000 Subject: [PATCH 24/29] Update driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java Co-authored-by: Valentin Kovalenko --- .../com/mongodb/client/WithTransactionProseTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index e38d99fd014..2441f13a741 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -256,8 +256,7 @@ void testRetryBackoffIsEnforced() { collection.insertOne(session, Document.parse("{ _id : 'backoff-test-no-jitter' }")); return null; }); - long endNanos = System.nanoTime(); - noBackoffTime = TimeUnit.NANOSECONDS.toMillis(endNanos - startNanos); + noBackoffTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);``` } finally { // Clear the test jitter supplier to avoid affecting other tests ExponentialBackoff.clearTestJitterSupplier(); From b70df05285a0dc625c3c10dc2b055e030bbfb249 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 14 Jan 2026 00:05:21 +0000 Subject: [PATCH 25/29] Update driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java Co-authored-by: Valentin Kovalenko --- .../com/mongodb/client/WithTransactionProseTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 2441f13a741..0bcb5a417fa 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -271,10 +271,7 @@ void testRetryBackoffIsEnforced() { long withBackoffTime; try (ClientSession session = client.startSession()) { long startNanos = System.nanoTime(); - session.withTransaction(() -> { - collection.insertOne(session, Document.parse("{ _id : 'backoff-test-full-jitter' }")); - return null; - }); + session.withTransaction(() -> collection.insertOne(session, Document.parse("{ _id : 'backoff-test-full-jitter' }"))); withBackoffTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); } finally { ExponentialBackoff.clearTestJitterSupplier(); From c2c1d44f982c43984edd46034d2ed14dc9e607b4 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 14 Jan 2026 00:06:46 +0000 Subject: [PATCH 26/29] Update driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java Co-authored-by: Valentin Kovalenko --- .../com/mongodb/client/WithTransactionProseTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 0bcb5a417fa..92ad8792c33 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -252,10 +252,7 @@ void testRetryBackoffIsEnforced() { long noBackoffTime; try (ClientSession session = client.startSession()) { long startNanos = System.nanoTime(); - session.withTransaction(() -> { - collection.insertOne(session, Document.parse("{ _id : 'backoff-test-no-jitter' }")); - return null; - }); + session.withTransaction(() -> collection.insertOne(session, Document.parse("{ _id : 'backoff-test-no-jitter' }"))); noBackoffTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);``` } finally { // Clear the test jitter supplier to avoid affecting other tests From 4dadb708650c4323664142c96dcea239e9ed0775 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Wed, 14 Jan 2026 01:02:28 +0000 Subject: [PATCH 27/29] Update driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java Co-authored-by: Valentin Kovalenko --- .../src/main/com/mongodb/internal/ExponentialBackoff.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java index 42a9ff1d385..0ac1e12ee4e 100644 --- a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java +++ b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java @@ -69,7 +69,8 @@ public ExponentialBackoff(final double baseBackoffMs, final double maxBackoffMs, /** * Creates a backoff instance configured for withTransaction retries. - * Uses: 5ms base, 500ms max, 1.5 growth factor. + * Uses: {@value TRANSACTION_BASE_BACKOFF_MS} ms base, {@value TRANSACTION_MAX_BACKOFF_MS} ms max, + * {@value TRANSACTION_BACKOFF_GROWTH} growth factor. * * @return ExponentialBackoff configured for transaction retries */ From b397236e91d9e96d54ae6009d546eedf2e82bf71 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Thu, 15 Jan 2026 23:25:48 +0000 Subject: [PATCH 28/29] PR feedback --- .../mongodb/internal/ExponentialBackoff.java | 192 ------------- .../internal/time/ExponentialBackoff.java | 88 ++++++ .../internal/ExponentialBackoffTest.java | 268 ++---------------- .../client/internal/ClientSessionImpl.java | 40 +-- .../client/WithTransactionProseTest.java | 97 +++---- 5 files changed, 180 insertions(+), 505 deletions(-) delete mode 100644 driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java create mode 100644 driver-core/src/main/com/mongodb/internal/time/ExponentialBackoff.java diff --git a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java deleted file mode 100644 index 0ac1e12ee4e..00000000000 --- a/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright 2008-present MongoDB, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.mongodb.internal; - -import com.mongodb.annotations.NotThreadSafe; - -import java.util.concurrent.ThreadLocalRandom; -import java.util.function.DoubleSupplier; - -import static com.mongodb.internal.VisibleForTesting.AccessModifier.PRIVATE; - -/** - * Implements exponential backoff with jitter for retry scenarios. - * Formula: delayMS = jitter * min(maxBackoffMs, baseBackoffMs * growthFactor^retryCount) - * where jitter is random value [0, 1). - * - *

This class provides factory methods for common use cases: - *

    - *
  • {@link #forTransactionRetry()} - For withTransaction retries (5ms base, 500ms max, 1.5 growth)
  • - *
  • {@link #forCommandRetry()} - For command retries with overload (100ms base, 10000ms max, 2.0 growth)
  • - *
- */ -@NotThreadSafe -public final class ExponentialBackoff { - // Transaction retry constants (per spec) - private static final double TRANSACTION_BASE_BACKOFF_MS = 5.0; - private static final double TRANSACTION_MAX_BACKOFF_MS = 500.0; - private static final double TRANSACTION_BACKOFF_GROWTH = 1.5; - - // Command retry constants (per spec) - private static final double COMMAND_BASE_BACKOFF_MS = 100.0; - private static final double COMMAND_MAX_BACKOFF_MS = 10000.0; - private static final double COMMAND_BACKOFF_GROWTH = 2.0; - - private final double baseBackoffMs; - private final double maxBackoffMs; - private final double growthFactor; - private int retryCount = 0; - - // Test-only jitter supplier - when set, overrides ThreadLocalRandom - private static volatile DoubleSupplier testJitterSupplier = null; - - /** - * Creates an exponential backoff instance with specified parameters. - * - * @param baseBackoffMs Initial backoff in milliseconds - * @param maxBackoffMs Maximum backoff cap in milliseconds - * @param growthFactor Exponential growth factor (e.g., 1.5 or 2.0) - */ - public ExponentialBackoff(final double baseBackoffMs, final double maxBackoffMs, final double growthFactor) { - this.baseBackoffMs = baseBackoffMs; - this.maxBackoffMs = maxBackoffMs; - this.growthFactor = growthFactor; - } - - /** - * Creates a backoff instance configured for withTransaction retries. - * Uses: {@value TRANSACTION_BASE_BACKOFF_MS} ms base, {@value TRANSACTION_MAX_BACKOFF_MS} ms max, - * {@value TRANSACTION_BACKOFF_GROWTH} growth factor. - * - * @return ExponentialBackoff configured for transaction retries - */ - public static ExponentialBackoff forTransactionRetry() { - return new ExponentialBackoff( - TRANSACTION_BASE_BACKOFF_MS, - TRANSACTION_MAX_BACKOFF_MS, - TRANSACTION_BACKOFF_GROWTH - ); - } - - /** - * Creates a backoff instance configured for command retries during overload. - * Uses: 100ms base, 10000ms max, 2.0 growth factor. - * - * @return ExponentialBackoff configured for command retries - */ - public static ExponentialBackoff forCommandRetry() { - return new ExponentialBackoff( - COMMAND_BASE_BACKOFF_MS, - COMMAND_MAX_BACKOFF_MS, - COMMAND_BACKOFF_GROWTH - ); - } - - /** - * Calculate next backoff delay with jitter. - * - * @return delay in milliseconds - */ - public long calculateDelayMs() { - // Use test jitter supplier if set, otherwise use ThreadLocalRandom - double jitter = testJitterSupplier != null - ? testJitterSupplier.getAsDouble() - : ThreadLocalRandom.current().nextDouble(); - double exponentialBackoff = baseBackoffMs * Math.pow(growthFactor, retryCount); - double cappedBackoff = Math.min(exponentialBackoff, maxBackoffMs); - retryCount++; - return Math.round(jitter * cappedBackoff); - } - - /** - * Calculate backoff delay with jitter for a specific retry count. - * This method does not modify the internal retry counter. - * - * @param retryCount the retry count to calculate delay for - * @return delay in milliseconds - */ - public long calculateDelayMs(final int retryCount) { - double jitter = testJitterSupplier != null - ? testJitterSupplier.getAsDouble() - : ThreadLocalRandom.current().nextDouble(); - double exponentialBackoff = baseBackoffMs * Math.pow(growthFactor, retryCount); - double cappedBackoff = Math.min(exponentialBackoff, maxBackoffMs); - return Math.round(jitter * cappedBackoff); - } - - /** - * Set a custom jitter supplier for testing purposes. - * This overrides the default ThreadLocalRandom jitter generation. - * - * @param supplier A DoubleSupplier that returns values in [0, 1) range, or null to use default - */ - @VisibleForTesting(otherwise = PRIVATE) - public static void setTestJitterSupplier(final DoubleSupplier supplier) { - testJitterSupplier = supplier; - } - - /** - * Clear the test jitter supplier, reverting to default ThreadLocalRandom behavior. - */ - @VisibleForTesting(otherwise = PRIVATE) - public static void clearTestJitterSupplier() { - testJitterSupplier = null; - } - - /** - * Reset retry counter for new sequence of retries. - */ - public void reset() { - retryCount = 0; - } - - /** - * Get current retry count for testing. - * - * @return current retry count - */ - public int getRetryCount() { - return retryCount; - } - - /** - * Get the base backoff in milliseconds. - * - * @return base backoff - */ - public double getBaseBackoffMs() { - return baseBackoffMs; - } - - /** - * Get the maximum backoff in milliseconds. - * - * @return maximum backoff - */ - public double getMaxBackoffMs() { - return maxBackoffMs; - } - - /** - * Get the growth factor. - * - * @return growth factor - */ - public double getGrowthFactor() { - return growthFactor; - } -} diff --git a/driver-core/src/main/com/mongodb/internal/time/ExponentialBackoff.java b/driver-core/src/main/com/mongodb/internal/time/ExponentialBackoff.java new file mode 100644 index 00000000000..2a69e8ba9e2 --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/time/ExponentialBackoff.java @@ -0,0 +1,88 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.internal.time; + +import com.mongodb.annotations.NotThreadSafe; +import com.mongodb.internal.VisibleForTesting; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.DoubleSupplier; + +import static com.mongodb.internal.VisibleForTesting.AccessModifier.PRIVATE; + +/** + * Implements exponential backoff with jitter for retry scenarios. + */ +@NotThreadSafe +public enum ExponentialBackoff { + TRANSACTION(5.0, 500.0, 1.5); + + private final double baseMs, maxMs, growth; + + // TODO remove this global state once https://jira.mongodb.org/browse/JAVA-6060 is done + private static DoubleSupplier testJitterSupplier = null; + + ExponentialBackoff(final double baseMs, final double maxMs, final double growth) { + this.baseMs = baseMs; + this.maxMs = maxMs; + this.growth = growth; + } + + /** + * Calculate the next delay in milliseconds based on the retry count. + * + * @param retryCount The number of retries that have occurred. + * @return The calculated delay in milliseconds. + */ + public long calculateDelayBeforeNextRetryMs(final int retryCount) { + double jitter = testJitterSupplier != null + ? testJitterSupplier.getAsDouble() + : ThreadLocalRandom.current().nextDouble(); + double backoff = Math.min(baseMs * Math.pow(growth, retryCount), maxMs); + return Math.round(jitter * backoff); + } + + /** + * Calculate the next delay in milliseconds based on the retry count and a provided jitter. + * + * @param retryCount The number of retries that have occurred. + * @param jitter A double in the range [0, 1) to apply as jitter. + * @return The calculated delay in milliseconds. + */ + public long calculateDelayBeforeNextRetryMs(final int retryCount, final double jitter) { + double backoff = Math.min(baseMs * Math.pow(growth, retryCount), maxMs); + return Math.round(jitter * backoff); + } + + /** + * Set a custom jitter supplier for testing purposes. + * + * @param supplier A DoubleSupplier that returns values in [0, 1) range. + */ + @VisibleForTesting(otherwise = PRIVATE) + public static void setTestJitterSupplier(final DoubleSupplier supplier) { + testJitterSupplier = supplier; + } + + /** + * Clear the test jitter supplier, reverting to default ThreadLocalRandom behavior. + */ + @VisibleForTesting(otherwise = PRIVATE) + public static void clearTestJitterSupplier() { + testJitterSupplier = null; + } +} diff --git a/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java b/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java index 84ab56a0e47..67238532488 100644 --- a/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java +++ b/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java @@ -16,7 +16,7 @@ package com.mongodb.internal; -import org.junit.jupiter.api.AfterEach; +import com.mongodb.internal.time.ExponentialBackoff; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -24,270 +24,50 @@ public class ExponentialBackoffTest { - @AfterEach - void cleanup() { - // Always clear the test jitter supplier after each test to avoid test pollution - ExponentialBackoff.clearTestJitterSupplier(); - } - @Test void testTransactionRetryBackoff() { - ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); - - // Verify configuration - assertEquals(5.0, backoff.getBaseBackoffMs()); - assertEquals(500.0, backoff.getMaxBackoffMs()); - assertEquals(1.5, backoff.getGrowthFactor()); - - // First retry (i=0): delay = jitter * min(5 * 1.5^0, 500) = jitter * 5 - // Since jitter is random [0,1), the delay should be between 0 and 5ms - long delay1 = backoff.calculateDelayMs(); - assertTrue(delay1 >= 0 && delay1 <= 5, "First delay should be 0-5ms, got: " + delay1); - - // Second retry (i=1): delay = jitter * min(5 * 1.5^1, 500) = jitter * 7.5 - long delay2 = backoff.calculateDelayMs(); - assertTrue(delay2 >= 0 && delay2 <= 8, "Second delay should be 0-8ms, got: " + delay2); - - // Third retry (i=2): delay = jitter * min(5 * 1.5^2, 500) = jitter * 11.25 - long delay3 = backoff.calculateDelayMs(); - assertTrue(delay3 >= 0 && delay3 <= 12, "Third delay should be 0-12ms, got: " + delay3); - - // Verify the retry count is incrementing properly - assertEquals(3, backoff.getRetryCount()); - } - - @Test - void testTransactionRetryBackoffRespectsMaximum() { - ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); - - // Advance to a high retry count where backoff would exceed 500ms without capping - for (int i = 0; i < 20; i++) { - backoff.calculateDelayMs(); - } - - // Even at high retry counts, delay should never exceed 500ms - for (int i = 0; i < 5; i++) { - long delay = backoff.calculateDelayMs(); - assertTrue(delay >= 0 && delay <= 500, "Delay should be capped at 500ms, got: " + delay); - } - } - - @Test - void testTransactionRetryBackoffSequenceWithExpectedValues() { // Test that the backoff sequence follows the expected pattern with growth factor 1.5 - // Expected sequence (without jitter): 5, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, ... + // Expected sequence (without jitter): 5, 7.5, 11.25, ... // With jitter, actual values will be between 0 and these maxima - - ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); - double[] expectedMaxValues = {5.0, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, 85.4296875, 128.14453125, 192.21679688, 288.32519531, 432.48779297, 500.0}; - for (int i = 0; i < expectedMaxValues.length; i++) { - long delay = backoff.calculateDelayMs(); - assertTrue(delay >= 0 && delay <= Math.round(expectedMaxValues[i]), String.format("Retry %d: delay should be 0-%d ms, got: %d", i, Math.round(expectedMaxValues[i]), delay)); + ExponentialBackoff backoff = ExponentialBackoff.TRANSACTION; + for (int retry = 0; retry < expectedMaxValues.length; retry++) { + long delay = backoff.calculateDelayBeforeNextRetryMs(retry); + assertTrue(delay >= 0 && delay <= Math.round(expectedMaxValues[retry]), String.format("Retry %d: delay should be 0-%d ms, got: %d", retry, Math.round(expectedMaxValues[retry]), delay)); } } @Test - void testCommandRetryBackoff() { - ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); - - // Verify configuration - assertEquals(100.0, backoff.getBaseBackoffMs()); - assertEquals(10000.0, backoff.getMaxBackoffMs()); - assertEquals(2.0, backoff.getGrowthFactor()); - - // Test sequence with growth factor 2.0 - // Expected max delays: 100, 200, 400, 800, 1600, 3200, 6400, 10000 (capped) - long delay1 = backoff.calculateDelayMs(); - assertTrue(delay1 >= 0 && delay1 <= 100, "First delay should be 0-100ms, got: " + delay1); - - long delay2 = backoff.calculateDelayMs(); - assertTrue(delay2 >= 0 && delay2 <= 200, "Second delay should be 0-200ms, got: " + delay2); - - long delay3 = backoff.calculateDelayMs(); - assertTrue(delay3 >= 0 && delay3 <= 400, "Third delay should be 0-400ms, got: " + delay3); - - long delay4 = backoff.calculateDelayMs(); - assertTrue(delay4 >= 0 && delay4 <= 800, "Fourth delay should be 0-800ms, got: " + delay4); - - long delay5 = backoff.calculateDelayMs(); - assertTrue(delay5 >= 0 && delay5 <= 1600, "Fifth delay should be 0-1600ms, got: " + delay5); - } - - @Test - void testCommandRetryBackoffRespectsMaximum() { - ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); - - // Advance to where exponential would exceed 10000ms - for (int i = 0; i < 10; i++) { - backoff.calculateDelayMs(); - } - - // Even at high retry counts, delay should never exceed 10000ms - for (int i = 0; i < 5; i++) { - long delay = backoff.calculateDelayMs(); - assertTrue(delay >= 0 && delay <= 10000, "Delay should be capped at 10000ms, got: " + delay); - } - } - - @Test - void testCustomBackoff() { - // Test with custom parameters - ExponentialBackoff backoff = new ExponentialBackoff(50.0, 2000.0, 1.8); - - assertEquals(50.0, backoff.getBaseBackoffMs()); - assertEquals(2000.0, backoff.getMaxBackoffMs()); - assertEquals(1.8, backoff.getGrowthFactor()); - - // First delay: 0-50ms - long delay1 = backoff.calculateDelayMs(); - assertTrue(delay1 >= 0 && delay1 <= 50, "First delay should be 0-50ms, got: " + delay1); - - // Second delay: 0-90ms (50 * 1.8) - long delay2 = backoff.calculateDelayMs(); - assertTrue(delay2 >= 0 && delay2 <= 90, "Second delay should be 0-90ms, got: " + delay2); - } - - @Test - void testReset() { - ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); - - // Perform some retries - backoff.calculateDelayMs(); - backoff.calculateDelayMs(); - assertEquals(2, backoff.getRetryCount()); - - // Reset and verify counter is back to 0 - backoff.reset(); - assertEquals(0, backoff.getRetryCount()); - - // First delay after reset should be in the initial range again - long delay = backoff.calculateDelayMs(); - assertTrue(delay >= 0 && delay <= 5, "First delay after reset should be 0-5ms, got: " + delay); - } - - @Test - void testCommandRetrySequenceMatchesSpec() { - // Test that command retry follows spec: 100ms * 2^i capped at 10000ms - ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); - - double[] expectedMaxValues = {100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0, 6400.0, 10000.0, 10000.0}; - - for (int i = 0; i < expectedMaxValues.length; i++) { - long delay = backoff.calculateDelayMs(); - double expectedMax = expectedMaxValues[i]; - assertTrue(delay >= 0 && delay <= Math.round(expectedMax), String.format("Retry %d: delay should be 0-%d ms, got: %d", i, Math.round(expectedMax), delay)); - } - } - - // Tests for the test jitter supplier functionality - - @Test - void testJitterSupplierWithZeroJitter() { - // Set jitter to always return 0 (no backoff) - ExponentialBackoff.setTestJitterSupplier(() -> 0.0); - - ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + void testTransactionRetryBackoffRespectsMaximum() { + ExponentialBackoff backoff = ExponentialBackoff.TRANSACTION; - // With jitter = 0, all delays should be 0 - for (int i = 0; i < 10; i++) { - long delay = backoff.calculateDelayMs(); - assertEquals(0, delay, "With jitter=0, delay should always be 0ms"); + // Even at high retry counts, delay should never exceed 500ms + for (int retry = 0; retry < 25; retry++) { + long delay = backoff.calculateDelayBeforeNextRetryMs(retry); + assertTrue(delay >= 0 && delay <= 500, String.format("Retry %d: delay should be capped at 500 ms, got: %d ms", retry, delay)); } } @Test - void testJitterSupplierWithFullJitter() { - // Set jitter to always return 1.0 (full backoff) - ExponentialBackoff.setTestJitterSupplier(() -> 1.0); - - ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); + void testCustomJitter() { + ExponentialBackoff backoff = ExponentialBackoff.TRANSACTION; // Expected delays with jitter=1.0 and growth factor 1.5 double[] expectedDelays = {5.0, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, 85.4296875, 128.14453125, 192.21679688, 288.32519531, 432.48779297, 500.0}; + double jitter = 1.0; - for (int i = 0; i < expectedDelays.length; i++) { - long delay = backoff.calculateDelayMs(); - long expected = Math.round(expectedDelays[i]); - assertEquals(expected, delay, String.format("Retry %d: with jitter=1.0, delay should be %dms", i, expected)); - } - } - - @Test - void testJitterSupplierWithHalfJitter() { - // Set jitter to always return 0.5 (half backoff) - ExponentialBackoff.setTestJitterSupplier(() -> 0.5); - - ExponentialBackoff backoff = ExponentialBackoff.forTransactionRetry(); - - // Expected delays with jitter=0.5 and growth factor 1.5 - double[] expectedMaxDelays = {5.0, 7.5, 11.25, 16.875, 25.3125, 37.96875, 56.953125, 85.4296875, 128.14453125, 192.21679688, 288.32519531, 432.48779297, 500.0}; - - for (int i = 0; i < expectedMaxDelays.length; i++) { - long delay = backoff.calculateDelayMs(); - long expected = Math.round(0.5 * expectedMaxDelays[i]); - assertEquals(expected, delay, String.format("Retry %d: with jitter=0.5, delay should be %dms", i, expected)); + for (int retry = 0; retry < expectedDelays.length; retry++) { + long delay = backoff.calculateDelayBeforeNextRetryMs(retry, jitter); + long expected = Math.round(expectedDelays[retry]); + assertEquals(expected, delay, String.format("Retry %d: with jitter=1.0, delay should be %d ms", retry, expected)); } - } - - @Test - void testJitterSupplierForCommandRetry() { - // Test that custom jitter also works with command retry configuration - ExponentialBackoff.setTestJitterSupplier(() -> 1.0); - - ExponentialBackoff backoff = ExponentialBackoff.forCommandRetry(); - - // Expected first few delays with jitter=1.0 and growth factor 2.0 - long[] expectedDelays = {100, 200, 400, 800, 1600, 3200, 6400, 10000}; - - for (int i = 0; i < expectedDelays.length; i++) { - long delay = backoff.calculateDelayMs(); - assertEquals(expectedDelays[i], delay, String.format("Command retry %d: with jitter=1.0, delay should be %dms", i, expectedDelays[i])); - } - } - - @Test - void testClearingJitterSupplierReturnsToRandom() { - // First set a fixed jitter - ExponentialBackoff.setTestJitterSupplier(() -> 0.0); - ExponentialBackoff backoff1 = ExponentialBackoff.forTransactionRetry(); - long delay1 = backoff1.calculateDelayMs(); - assertEquals(0, delay1, "With jitter=0, delay should be 0ms"); - - // Clear the test jitter supplier - ExponentialBackoff.clearTestJitterSupplier(); - - // Now delays should be random again - ExponentialBackoff backoff2 = ExponentialBackoff.forTransactionRetry(); - - // Run multiple times to verify randomness (statistically very unlikely to get all zeros) - boolean foundNonZero = false; - for (int i = 0; i < 20; i++) { - long delay = backoff2.calculateDelayMs(); - assertTrue(delay >= 0 && delay <= Math.round(5.0 * Math.pow(1.5, i)), "Delay should be within expected range"); - if (delay > 0) { - foundNonZero = true; - } + // With jitter = 0, all delays should be 0 + jitter = 0; + for (int retry = 0; retry < 10; retry++) { + long delay = backoff.calculateDelayBeforeNextRetryMs(retry, jitter); + assertEquals(0, delay, "With jitter=0, delay should always be 0 ms"); } - assertTrue(foundNonZero, "After clearing test jitter, should get some non-zero delays (random behavior)"); - } - - @Test - void testJitterSupplierWithCustomBackoff() { - // Test that custom jitter works with custom backoff parameters - ExponentialBackoff.setTestJitterSupplier(() -> 0.75); - - ExponentialBackoff backoff = new ExponentialBackoff(100.0, 1000.0, 2.5); - - // First delay: 0.75 * 100 = 75 - assertEquals(75, backoff.calculateDelayMs()); - - // Second delay: 0.75 * 100 * 2.5 = 0.75 * 250 = 188 (rounded) - assertEquals(188, backoff.calculateDelayMs()); - - // Third delay: 0.75 * 100 * 2.5^2 = 0.75 * 625 = 469 (rounded) - assertEquals(469, backoff.calculateDelayMs()); } } diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index ddbc0ecf205..fcaea52aaa1 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -27,7 +27,6 @@ import com.mongodb.WriteConcern; import com.mongodb.client.ClientSession; import com.mongodb.client.TransactionBody; -import com.mongodb.internal.ExponentialBackoff; import com.mongodb.internal.TimeoutContext; import com.mongodb.internal.observability.micrometer.TracingManager; import com.mongodb.internal.observability.micrometer.TransactionSpan; @@ -39,6 +38,7 @@ import com.mongodb.internal.operation.WriteOperation; import com.mongodb.internal.session.BaseClientSessionImpl; import com.mongodb.internal.session.ServerSessionPool; +import com.mongodb.internal.time.ExponentialBackoff; import com.mongodb.lang.Nullable; import static com.mongodb.MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL; @@ -47,6 +47,7 @@ import static com.mongodb.assertions.Assertions.assertTrue; import static com.mongodb.assertions.Assertions.isTrue; import static com.mongodb.assertions.Assertions.notNull; +import static com.mongodb.internal.thread.InterruptionUtil.interruptAndCreateMongoInterruptedException; final class ClientSessionImpl extends BaseClientSessionImpl implements ClientSession { @@ -252,7 +253,7 @@ public T withTransaction(final TransactionBody transactionBody, final Tra notNull("transactionBody", transactionBody); long startTime = ClientSessionClock.INSTANCE.now(); TimeoutContext withTransactionTimeoutContext = createTimeoutContext(options); - ExponentialBackoff transactionBackoff = ExponentialBackoff.forTransactionRetry(); + ExponentialBackoff transactionBackoff = ExponentialBackoff.TRANSACTION; int transactionAttempt = 0; MongoException lastError = null; @@ -260,22 +261,7 @@ public T withTransaction(final TransactionBody transactionBody, final Tra outer: while (true) { if (transactionAttempt > 0) { - long backoffMs = transactionBackoff.calculateDelayMs(transactionAttempt - 1); - // Check if backoff would exceed timeout - if (ClientSessionClock.INSTANCE.now() + backoffMs - startTime >= MAX_RETRY_TIME_LIMIT_MS) { - if (lastError != null) { - throw lastError; - } - throw new MongoClientException("Transaction retry timeout exceeded"); - } - try { - if (backoffMs > 0) { - Thread.sleep(backoffMs); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new MongoClientException("Transaction retry interrupted", e); - } + backoff(transactionBackoff, transactionAttempt, startTime, lastError); } T retVal; try { @@ -387,4 +373,22 @@ private TimeoutContext createTimeoutContext(final TransactionOptions transaction TransactionOptions.merge(transactionOptions, getOptions().getDefaultTransactionOptions()), operationExecutor.getTimeoutSettings())); } + + private static void backoff(final ExponentialBackoff exponentialBackoff, final int transactionAttempt, final long startTime, + final MongoException lastError) { + long backoffMs = exponentialBackoff.calculateDelayBeforeNextRetryMs(transactionAttempt - 1); + if (ClientSessionClock.INSTANCE.now() + backoffMs - startTime >= MAX_RETRY_TIME_LIMIT_MS) { + if (lastError != null) { + throw lastError; + } + throw new MongoClientException("Transaction retry timeout exceeded"); + } + try { + if (backoffMs > 0) { + Thread.sleep(backoffMs); + } + } catch (InterruptedException e) { + throw interruptAndCreateMongoInterruptedException("Transaction retry interrupted", e); + } + } } diff --git a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java index 92ad8792c33..e2dce11583f 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -22,7 +22,8 @@ import com.mongodb.TransactionOptions; import com.mongodb.client.internal.ClientSessionClock; import com.mongodb.client.model.Sorts; -import com.mongodb.internal.ExponentialBackoff; +import com.mongodb.internal.time.ExponentialBackoff; +import org.bson.BsonDocument; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -34,6 +35,7 @@ import static com.mongodb.ClusterFixture.TIMEOUT; import static com.mongodb.ClusterFixture.isDiscoverableReplicaSet; import static com.mongodb.ClusterFixture.isSharded; +import static com.mongodb.client.Fixture.getPrimary; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -206,83 +208,76 @@ public void testTimeoutMSAndLegacySettings() { } } - // - // Test that exponential backoff is applied when retrying transactions - // Backoff uses growth factor of 1.5 as per spec - // - @Test - void testExponentialBackoffOnTransientError() { - // Configure failpoint to simulate transient errors - MongoDatabase failPointAdminDb = client.getDatabase("admin"); - failPointAdminDb.runCommand( - Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 3}, " - + "'data': {'failCommands': ['insert'], 'errorCode': 112, " - + "'errorLabels': ['TransientTransactionError']}}")); - - try (ClientSession session = client.startSession()) { - // Track retry count - AtomicInteger retryCount = new AtomicInteger(0); - - session.withTransaction(() -> { - retryCount.incrementAndGet(); // Count the attempt before the operation that might fail - return collection.insertOne(session, Document.parse("{ _id : 'backoff-test' }")); - }); - - assertEquals(4, retryCount.get(), "Expected 1 initial attempt + 3 retries"); - } finally { - failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); - } - } - - // - // Test that retries within withTransaction do not occur immediately - // This test verifies that exponential backoff is enforced during commit retries - // See: https://github.com/mongodb/specifications/blob/master/source/transactions-convenient-api/tests/README.md#retry-backoff-is-enforced - // + /** + * See + * Convenient API Prose Tests. + */ @DisplayName("Retry Backoff is Enforced") @Test - void testRetryBackoffIsEnforced() { - MongoDatabase failPointAdminDb = client.getDatabase("admin"); - - // Test 1: Run with jitter = 0 (no backoff) + public void testRetryBackoffIsEnforced() throws InterruptedException { + // Run with jitter = 0 (no backoff) ExponentialBackoff.setTestJitterSupplier(() -> 0.0); - failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 13}, " + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 251}}")); + BsonDocument failPointDocument = BsonDocument.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 13}, " + + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 251}}"); long noBackoffTime; - try (ClientSession session = client.startSession()) { + try (ClientSession session = client.startSession(); + FailPoint ignored = FailPoint.enable(failPointDocument, getPrimary())) { long startNanos = System.nanoTime(); - session.withTransaction(() -> collection.insertOne(session, Document.parse("{ _id : 'backoff-test-no-jitter' }"))); - noBackoffTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);``` + session.withTransaction(() -> collection.insertOne(session, Document.parse("{}"))); + noBackoffTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); } finally { // Clear the test jitter supplier to avoid affecting other tests ExponentialBackoff.clearTestJitterSupplier(); - failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); } - // Test 2: Run with jitter = 1 (full backoff) + // Run with jitter = 1 (full backoff) ExponentialBackoff.setTestJitterSupplier(() -> 1.0); - failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 13}, " + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 251}}")); + failPointDocument = BsonDocument.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 13}, " + + "'data': {'failCommands': ['commitTransaction'], 'errorCode': 251}}"); long withBackoffTime; - try (ClientSession session = client.startSession()) { + try (ClientSession session = client.startSession(); + FailPoint ignored = FailPoint.enable(failPointDocument, getPrimary())) { long startNanos = System.nanoTime(); - session.withTransaction(() -> collection.insertOne(session, Document.parse("{ _id : 'backoff-test-full-jitter' }"))); + session.withTransaction(() -> collection.insertOne(session, Document.parse("{}"))); withBackoffTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); } finally { ExponentialBackoff.clearTestJitterSupplier(); - failPointAdminDb.runCommand(Document.parse("{'configureFailPoint': 'failCommand', 'mode': 'off'}")); } - long expectedWithBackoffTime = noBackoffTime + 1800; // 1.8 seconds as per spec + long expectedWithBackoffTime = noBackoffTime + 1800; long actualDifference = Math.abs(withBackoffTime - expectedWithBackoffTime); - assertTrue(actualDifference < 1000, String.format("Expected withBackoffTime to be ~%dms (noBackoffTime %dms + 1800ms), but" - + " got %dms. Difference: %dms (tolerance: 1000ms per spec)", expectedWithBackoffTime, noBackoffTime, withBackoffTime, + assertTrue(actualDifference < 1000, String.format("Expected withBackoffTime to be ~% dms (noBackoffTime %d ms + 1800 ms), but" + + " got %d ms. Difference: %d ms (tolerance: 1000 ms per spec)", expectedWithBackoffTime, noBackoffTime, withBackoffTime, actualDifference)); } + /** + * This test is not from the specification. + */ + @Test + public void testExponentialBackoffOnTransientError() throws InterruptedException { + BsonDocument failPointDocument = BsonDocument.parse("{'configureFailPoint': 'failCommand', 'mode': {'times': 3}, " + + "'data': {'failCommands': ['insert'], 'errorCode': 112, " + + "'errorLabels': ['TransientTransactionError']}}"); + + try (ClientSession session = client.startSession(); + FailPoint ignored = FailPoint.enable(failPointDocument, getPrimary())) { + AtomicInteger attemptsCount = new AtomicInteger(0); + + session.withTransaction(() -> { + attemptsCount.incrementAndGet(); // Count the attempt before the operation that might fail + return collection.insertOne(session, Document.parse("{}")); + }); + + assertEquals(4, attemptsCount.get(), "Expected 1 initial attempt + 3 retries"); + } + } + private boolean canRunTests() { return isSharded() || isDiscoverableReplicaSet(); } From 5c2145c927b5c4058871208526428a26969f11a1 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Fri, 16 Jan 2026 00:24:22 +0000 Subject: [PATCH 29/29] remove annotation --- .../src/main/com/mongodb/internal/time/ExponentialBackoff.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/driver-core/src/main/com/mongodb/internal/time/ExponentialBackoff.java b/driver-core/src/main/com/mongodb/internal/time/ExponentialBackoff.java index 2a69e8ba9e2..ed9bba51d7f 100644 --- a/driver-core/src/main/com/mongodb/internal/time/ExponentialBackoff.java +++ b/driver-core/src/main/com/mongodb/internal/time/ExponentialBackoff.java @@ -16,7 +16,6 @@ package com.mongodb.internal.time; -import com.mongodb.annotations.NotThreadSafe; import com.mongodb.internal.VisibleForTesting; import java.util.concurrent.ThreadLocalRandom; @@ -27,7 +26,6 @@ /** * Implements exponential backoff with jitter for retry scenarios. */ -@NotThreadSafe public enum ExponentialBackoff { TRANSACTION(5.0, 500.0, 1.5);