-
Type:
Bug
-
Resolution: Unresolved
-
Priority:
Critical - P2
-
None
-
Affects Version/s: 3.7.0, 3.8.0, 3.9.0, 3.10.0, 3.11.0, 3.12.0
-
Component/s: Transactions
-
None
-
None
-
Dotnet Drivers
-
None
-
None
-
None
-
None
-
None
-
None
Summary
After upgrading MongoDB.Driver from 3.5.2 to 3.12.0, IClientSessionHandle.WithTransaction[Async] no longer retries write conflicts. The first TransientTransactionError ends the call with System.TimeoutException: The operation has timed out after a single attempt, typically within milliseconds, instead of retrying the callback until the 120-second limit.
This happens when the client is created from a MongoClientSettings built with its constructor (new MongoClientSettings { ... }{}). The regression starts in 3.7.0, where the callback's exception is rethrown without a retry. Since 3.8.0 it is wrapped in the TimeoutException.
The cause: the constructor sets the (internal) MongoClientSettings.Timeout to Timeout.InfiniteTimeSpan, while MongoClientSettings.FromConnectionString / FromUrl leave it null. With InfiniteTimeSpan, TransactionExecutor.IsTimedOut treats that value as a finite deadline of −1 ms, so every retry looks as if it would exceed the budget.
Driver: 3.7.0 through 3.12.0. Reproduced on 3.12.0. TransactionExecutor.cs is unchanged between 3.8.0 and 3.12.0, and 3.7.0 differs only in the missing TimeoutException wrapping. Server: 8.3.11, single-node replica set.
How to Reproduce
_// Settings built with the constructor: the internal Timeout is InfiniteTimeSpan.
// MongoClientSettings.FromConnectionString / FromUrl leave it null, and then the driver retries as expected.
var client = new MongoClient(new MongoClientSettings
{
Servers = new[]
,
ReplicaSetName = "rs0",
});
var collection = client.GetDatabase("test").GetCollection<BsonDocument>("withTransactionRepro");
await collection.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
await collection.InsertOneAsync(new BsonDocument("_id", 1));
var filter = Builders<BsonDocument>.Filter.Eq("_id", 1);
var update = Builders<BsonDocument>.Update.Inc("n", 1);
// Leave an uncommitted write on the document in another transaction.
using var blockingSession = await client.StartSessionAsync();
blockingSession.StartTransaction();
await collection.UpdateOneAsync(blockingSession, filter, update);
using var session = await client.StartSessionAsync();
var attempts = 0;
var withTransaction = session.WithTransactionAsync(async (s, ct) =>
{
attempts++;
await collection.UpdateOneAsync(s, filter, update, cancellationToken: ct); // WriteConflict, TransientTransactionError
return attempts;
});
await Task.Delay(200);
await blockingSession.CommitTransactionAsync();
await withTransaction;_
Expected: the driver retries the callback with backoff, and the call succeeds once blockingSession commits (attempts > 1).
Actual:
- 3.8.0-3.12.0: System.TimeoutException: The operation has timed out with the WriteConflict MongoCommandException as the inner exception, and attempts == 1.
- 3.7.0: the WriteConflict MongoCommandException is rethrown, and attempts == 1.
The same code with new MongoClient(connectionString) passes: FromUrl leaves Timeout null, so the 120-second path is taken. Both MongoClientSettings.Timeout and TransactionOptions.Timeout are internal in release builds (// TODO: CSOT: Make it public when CSOT will be ready for GA), and timeoutMS in the connection string is parsed only under #if DEBUG. So an application that builds its settings with the constructor cannot opt out of the infinite value through the public API.
Reproduced on 3.12.0: System.TimeoutException : The operation has timed out, thrown from TransactionExecutor.ShouldRetryTransaction, inner MongoCommandException (WriteConflict).
Additional Background
Where it breaks: TransactionExecutor.cs (v3.12.0).
_// L41-42: the infinite value is passed through unchanged
var transactionTimeout = transactionOptions?.Timeout ?? clientSession.Options.DefaultTransactionOptions?.Timeout;
using var operationContext = new OperationContext(clock, transactionTimeout, cancellationToken);
// L124-132
private static bool IsTimedOut(OperationContext operationContext, TimeSpan delay = default)
{
if (operationContext.Timeout.HasValue) // true for InfiniteTimeSpan
return operationContext.RootContext.Elapsed + delay >= __transactionTimeout;
}
// L261-279
delay = TimeSpan.FromMilliseconds(RetryabilityHelper.GetRetryDelayMs(random, attempt, 1.5, 5, 500));
if (IsTimedOut(operationContext, delay))
{
delay = TimeSpan.Zero;
if (operationContext.IsRootContextTimeoutConfigured()) // Timeout.HasValue, true for InfiniteTimeSpan
...
return false;
}
// L253-259: commit retries are affected the same way
return HasErrorLabel(ex, UnknownTransactionCommitResultLabel) &&
!IsTimedOut(operationContext) &&
!IsMaxTimeMSExpiredException(ex);_
Where the infinite value comes from:
- MongoClientSettings.cs L137 (constructor): _timeout = System.Threading.Timeout.InfiniteTimeSpan;
- MongoClient.cs L673 copies it into the session's default transaction options: if (_settings.Timeout.HasValue && options.DefaultTransactionOptions?.Timeout == null).
- OperationContext keeps InfiniteTimeSpan as is (Ensure.IsNullOrInfiniteOrGreaterThanOrEqualToZero). Elsewhere it treats that value as "no deadline", for example RemainingTimeout: if (Timeout == null || Timeout == System.Threading.Timeout.InfiniteTimeSpan). TransactionExecutor.IsTimedOut does not.
Before (v3.5.2): TransactionExecutor.cs L97-101.
_{{private static bool HasTimedOut(OperationContext operationContext)
{
return operationContext.IsTimedOut() ||
(operationContext.RootContext.Timeout == null && operationContext.RootContext.Elapsed > __transactionTimeout);
}}}_
OperationContext.IsTimedOut() is based on RemainingTimeout, which is infinite for InfiniteTimeSpan, so an infinite timeout never expired.
Introduced by:
CSHARP-5712"withTransaction API retries too frequently" (3.7.0), PR #1841, commit 22ed045f840b. It replaced HasTimedOut with IsTimedOut(operationContext, delay).CSHARP-5869"Clarify expected error if backoff exceeds CSOT's deadline in withTransaction" (3.8.0), PR #1921, commit 743f78d5d77c. It added the TimeoutException wrapping.
Related: CSHARP-3552 (CSOT: Transactions), CSHARP-5958 (withTransaction timeout error wrapping). This report is not about the wrapping semantics settled there ("For CSOT scenario we are throwing idiomatic TimeoutException"). The application here never enables CSOT: the constructor default makes the driver behave as if it had, with a deadline that has already expired.
Suggested fix:
- In TransactionExecutor.IsTimedOut and IsRootContextTimeoutConfigured, treat Timeout == InfiniteTimeSpan as "no deadline", for example the same way as OperationContext.RemainingTimeout.
- Make the MongoClientSettings constructor default Timeout to null (unset), like FromUrl, so that withTransaction falls back to the 120-second limit.
- related to
-
CSHARP-3552 CSOT: Transactions
-
- Closed
-
-
CSHARP-5958 Refine withTransaction timeout error wrapping semantics and label propagation in spec and prose tests
-
- Closed
-
-
CSHARP-5712 withTransaction API retries too frequently
-
- Closed
-