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 0000000000..0a01d38e27 --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/ExponentialBackoff.java @@ -0,0 +1,175 @@ +/* + * 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: + *

+ */ +@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: 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() { + // 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); + } + + /** + * 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/test/unit/com/mongodb/internal/ExponentialBackoffTest.java b/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java new file mode 100644 index 0000000000..84ab56a0e4 --- /dev/null +++ b/driver-core/src/test/unit/com/mongodb/internal/ExponentialBackoffTest.java @@ -0,0 +1,293 @@ +/* + * 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.AfterEach; +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 { + + @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, ... + // 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 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(); + + // 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/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index aa1414dce5..59ef120a08 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -28,6 +28,7 @@ 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 +252,38 @@ 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: while (true) { + // Apply exponential backoff before retrying transaction + if (isRetry) { + 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 { + if (backoffMs > 0) { + Thread.sleep(backoffMs); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MongoClientException("Transaction retry interrupted", e); + } + } + isRetry = true; T retVal; try { startTransaction(options, withTransactionTimeoutContext.copyTimeoutContext()); @@ -269,7 +298,8 @@ 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) { + lastError = exceptionToHandle; // Track the last error for timeout scenarios if (transactionSpan != null) { transactionSpan.spanFinalizing(false); } @@ -284,9 +314,10 @@ 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 < 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/AbstractClientSideOperationsTimeoutProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java index 9ce58b1654..b4694e143a 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideOperationsTimeoutProseTest.java @@ -675,7 +675,9 @@ public void test10CustomTestWithTransactionUsesASingleTimeout() { } @DisplayName("10. Convenient Transactions - Custom Test: with transaction uses a single timeout - lock") - @Test + // 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) public void test10CustomTestWithTransactionUsesASingleTimeoutWithLock() { assumeTrue(serverVersionAtLeast(4, 4)); assumeFalse(isStandalone()); 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 1afbf61565..bcd52025ac 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/WithTransactionProseTest.java @@ -22,11 +22,14 @@ 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.DisplayName; 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 +206,90 @@ 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()) { + // 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; + }); + + 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 + // + @DisplayName("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(); }