ExportXMLWordPrintableJSON

    • 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[]

      { new MongoServerAddress("localhost", 27017) }

      ,
      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.Elapsed + delay >= operationContext.Timeout; // X >= -1 ms is always true }

      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

      { throw new TimeoutException("The operation has timed out", ex); }

      ...
      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:

      1. In TransactionExecutor.IsTimedOut and IsRootContextTimeoutConfigured, treat Timeout == InfiniteTimeSpan as "no deadline", for example the same way as OperationContext.RemainingTimeout.
      2. Make the MongoClientSettings constructor default Timeout to null (unset), like FromUrl, so that withTransaction falls back to the 120-second limit.

       

            Assignee:
            Unassigned
            Reporter:
            flibustier seas (EXT)
            None
            Votes:
            0 Vote for this issue
            Watchers:
            2 Start watching this issue

              Created:
              Updated: