From cc6856f9f64b1023beb6c0aad075c2e0f544f2f1 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Mon, 16 Mar 2026 11:53:13 +0100 Subject: [PATCH 1/2] fix(NODE-7430): throw timeout error when `withTransaction` retries exceed deadline --- src/sessions.ts | 172 +++++++++--------- .../transactions-convenient-api.prose.test.ts | 164 ++++++++++++++++- .../convenient-transactions.json | 107 ++++++++++- .../convenient-transactions.yml | 65 +++++++ 4 files changed, 423 insertions(+), 85 deletions(-) diff --git a/src/sessions.ts b/src/sessions.ts index ea209b63d10..09fa63c4349 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -17,6 +17,7 @@ import { MongoErrorLabel, MongoExpiredSessionError, MongoInvalidArgumentError, + MongoOperationTimeoutError, MongoRuntimeError, MongoServerError, MongoTransactionError, @@ -725,7 +726,7 @@ export class ClientSession timeoutMS?: number; } ): Promise { - const MAX_TIMEOUT = 120000; + const MAX_TIMEOUT = 120_000; const timeoutMS = options?.timeoutMS ?? this.timeoutMS ?? null; this.timeoutContext = @@ -737,10 +738,21 @@ export class ClientSession }) : null; - // 1. Record the current monotonic time, which will be used to enforce the 120-second timeout before later retry attempts. - const startTime = this.timeoutContext?.csotEnabled() // This is strictly to appease TS. We must narrow the context to a CSOT context before accessing `.start`. - ? this.timeoutContext.start - : processTimeMS(); + // 1. Define the following: + // 1.1 Record the current monotonic time, which will be used to enforce the timeout before later retry attempts. + // 1.2 Set `transactionAttempt` to `0`. + // 1.3 Set `TIMEOUT_MS` to be `timeoutMS` if given, otherwise MAX_TIMEOUT (120-seconds). + // + // The spec describes timeout checks as "elapsed time < TIMEOUT_MS" (where elapsed = now - start). + // We precompute `deadline = start + TIMEOUT_MS` so each check becomes simply `now < deadline`. + // + // Note 1: When TIMEOUT_MS is reached, we MUST report a timeout error wrapping the last error that + // triggered retry. With CSOT this is a MongoOperationTimeoutError; without CSOT the raw error + // is propagated directly. See makeTimeoutError() below. + const csotEnabled = !!this.timeoutContext?.csotEnabled(); + const deadline = this.timeoutContext?.csotEnabled() + ? processTimeMS() + this.timeoutContext.remainingTimeMS + : processTimeMS() + MAX_TIMEOUT; let committed = false; let result: T; @@ -749,20 +761,16 @@ export class ClientSession try { retryTransaction: for ( - // 2. Set `transactionAttempt` to `0`. + // 1.2 Set `transactionAttempt` to `0`. let transactionAttempt = 0, isRetry = false; !committed; ++transactionAttempt, isRetry = transactionAttempt > 0 ) { // 2. If `transactionAttempt` > 0: if (isRetry) { - // 2.i If elapsed time + `backoffMS` > `TIMEOUT_MS`, then raise the previously encountered error. If the elapsed time of - // `withTransaction` is less than TIMEOUT_MS, calculate the backoffMS to be - // `jitter * min(BACKOFF_INITIAL * 1.5 ** (transactionAttempt - 1), BACKOFF_MAX)`. sleep for `backoffMS`. - // 2.i.i jitter is a random float between \[0, 1) - // 2.i.ii `transactionAttempt` is the variable defined in step 1. - // 2.i.iii `BACKOFF_INITIAL` is 5ms - // 2.i.iv `BACKOFF_MAX` is 500ms + // 2.1 Calculate backoffMS. If elapsed time + backoffMS > TIMEOUT_MS + // (i.e., now + backoff >= deadline), raise the previously encountered error (see Note 1). + // Otherwise, sleep for backoffMS. const BACKOFF_INITIAL_MS = 5; const BACKOFF_MAX_MS = 500; const BACKOFF_GROWTH = 1.5; @@ -774,30 +782,26 @@ export class ClientSession BACKOFF_MAX_MS ); - const willExceedTransactionDeadline = - (this.timeoutContext?.csotEnabled() && - backoffMS > this.timeoutContext.remainingTimeMS) || - processTimeMS() + backoffMS > startTime + MAX_TIMEOUT; - - if (willExceedTransactionDeadline) { - throw ( + if (processTimeMS() + backoffMS >= deadline) { + throw makeTimeoutError( lastError ?? - new MongoRuntimeError( - `Transaction retry did not record an error: should never occur. Please file a bug.` - ) + new MongoRuntimeError( + `Transaction retry did not record an error: should never occur. Please file a bug.` + ), + csotEnabled ); } await setTimeout(backoffMS); } - // 3. Invoke startTransaction on the session - // 4. If `startTransaction` reported an error, propagate that error to the caller of `withTransaction` and return immediately. + // 3. Invoke startTransaction on the session and increment transactionAttempt. + // 4. If startTransaction reported an error, propagate that error to the caller and return immediately. this.startTransaction(options); // may throw on error try { // 5. Invoke the callback. - // 6. Control returns to withTransaction. (continued below) + // 6. Control returns to withTransaction. Determine the current state and whether the callback reported an error. const promise = fn(this); if (!isPromiseLike(promise)) { throw new MongoInvalidArgumentError( @@ -807,17 +811,16 @@ export class ClientSession result = await promise; - // 6. (cont.) Determine the current state of the ClientSession (continued below) + // 8. If the ClientSession is in the "no transaction", "transaction aborted", or "transaction committed" + // state, assume the callback intentionally aborted or committed the transaction and return immediately. + // Drivers MAY allow the callback to return a value to be propagated as the return value of withTransaction. if ( this.transaction.state === TxnState.NO_TRANSACTION || this.transaction.state === TxnState.TRANSACTION_COMMITTED || this.transaction.state === TxnState.TRANSACTION_ABORTED ) { - // 8. If the ClientSession is in the "no transaction", "transaction aborted", or "transaction committed" state, - // assume the callback intentionally aborted or committed the transaction and return immediately. return result; } - // 5. (cont.) and whether the callback reported an error // 7. If the callback reported an error: } catch (fnError) { if (!(fnError instanceof MongoError) || fnError instanceof MongoInvalidArgumentError) { @@ -827,83 +830,69 @@ export class ClientSession throw fnError; } + lastError = fnError; + + // 7.1 If the ClientSession is in the "starting transaction" or "transaction in progress" + // state, invoke abortTransaction on the session. if ( this.transaction.state === TxnState.STARTING_TRANSACTION || this.transaction.state === TxnState.TRANSACTION_IN_PROGRESS ) { - // 7.i If the ClientSession is in the "starting transaction" or "transaction in progress" state, - // invoke abortTransaction on the session await this.abortTransaction(); } - if ( - fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && - (this.timeoutContext?.csotEnabled() || processTimeMS() - startTime < MAX_TIMEOUT) - ) { - // 7.ii If the callback's error includes a "TransientTransactionError" label and the elapsed time of `withTransaction` - // is less than 120 seconds, jump back to step two. - lastError = fnError; + // 7.2 If the callback's error includes a "TransientTransactionError" label, jump back to step two. + if (fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) { continue retryTransaction; } - // 7.iii If the callback's error includes a "UnknownTransactionCommitResult" label, the callback must have manually committed a transaction, - // propagate the callback's error to the caller of withTransaction and return immediately. - // The 7.iii check is redundant with 6.iv, so we don't write code for it - // 7.iv Otherwise, propagate the callback's error to the caller of withTransaction and return immediately. + // 7.3 If the callback's error includes a "UnknownTransactionCommitResult" label, the callback + // must have manually committed a transaction, propagate the error and return immediately. + // (This check is redundant with step 8, so we don't write code for it.) + // 7.4 Otherwise, propagate the callback's error (see Note 1) and return immediately. throw fnError; } + // 9. Invoke commitTransaction on the session. + // We will rely on ClientSession.commitTransaction() to apply a majority write concern + // if commitTransaction is being retried (see: DRIVERS-601). retryCommit: while (!committed) { try { - /* - * We will rely on ClientSession.commitTransaction() to - * apply a majority write concern if commitTransaction is - * being retried (see: DRIVERS-601) - */ - // 9. Invoke commitTransaction on the session. await this.commitTransaction(); committed = true; - // 10. If commitTransaction reported an error: } catch (commitError) { - // If CSOT is enabled, we repeatedly retry until timeoutMS expires. This is enforced by providing a - // timeoutContext to each async API, which know how to cancel themselves (i.e., the next retry will - // abort the withTransaction call). - // If CSOT is not enabled, do we still have time remaining or have we timed out? - const hasTimedOut = - !this.timeoutContext?.csotEnabled() && processTimeMS() - startTime >= MAX_TIMEOUT; - - if (!hasTimedOut) { - /* - * Note: a maxTimeMS error will have the MaxTimeMSExpired - * code (50) and can be reported as a top-level error or - * inside writeConcernError, ex. - * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' } - * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } } - */ - if ( - !isMaxTimeMSExpiredError(commitError) && - commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) - ) { - // 10.i If the `commitTransaction` error includes a "UnknownTransactionCommitResult" label and the error is not - // MaxTimeMSExpired and the elapsed time of `withTransaction` is less than 120 seconds, jump back to step eight. - continue retryCommit; - } - - if (commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) { - // 10.ii If the commitTransaction error includes a "TransientTransactionError" label - // and the elapsed time of withTransaction is less than 120 seconds, jump back to step two. - lastError = commitError; - - continue retryTransaction; - } + // 10. If commitTransaction reported an error: + lastError = commitError; + + // If elapsed time >= TIMEOUT_MS (i.e., now >= deadline), raise a timeout error (see Note 1). + if (processTimeMS() >= deadline) { + throw makeTimeoutError(commitError, csotEnabled); + } + + // 10.1 If the error includes "UnknownTransactionCommitResult" and is not MaxTimeMSExpired + // and elapsed time < TIMEOUT_MS (guaranteed — deadline check above), jump back to step nine. + // Note: a maxTimeMS error will have the MaxTimeMSExpired code (50) and can be reported + // as a top-level error or inside writeConcernError. + if ( + !isMaxTimeMSExpiredError(commitError) && + commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) + ) { + continue retryCommit; } - // 10.iii Otherwise, propagate the commitTransaction error to the caller of withTransaction and return immediately. + // 10.2 If the error includes "TransientTransactionError" and elapsed time < TIMEOUT_MS + // (guaranteed — deadline check above), jump back to step two. + if (commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) { + continue retryTransaction; + } + + // 10.3 Otherwise, propagate the commitTransaction error (see Note 1) and return immediately. throw commitError; } } } + // 11. The transaction was committed successfully. Return immediately. // @ts-expect-error Result is always defined if we reach here, the for-loop above convinces TS it is not. return result; } finally { @@ -912,6 +901,25 @@ export class ClientSession } } +function makeTimeoutError(cause: Error, csotEnabled: boolean): Error { + // Async APIs know how to cancel themselves and might return CSOT error + if (cause instanceof MongoOperationTimeoutError) { + return cause; + } + if (csotEnabled) { + const timeoutError = new MongoOperationTimeoutError('Timed out during withTransaction', { + cause + }); + if (cause instanceof MongoError) { + for (const label of cause.errorLabels) { + timeoutError.addErrorLabel(label); + } + } + return timeoutError; + } + return cause; +} + const NON_DETERMINISTIC_WRITE_CONCERN_ERRORS = new Set([ 'CannotSatisfyWriteConcern', 'UnknownReplWriteConcern', diff --git a/test/integration/transactions-convenient-api/transactions-convenient-api.prose.test.ts b/test/integration/transactions-convenient-api/transactions-convenient-api.prose.test.ts index 1f4e67687de..6e304eab4e7 100644 --- a/test/integration/transactions-convenient-api/transactions-convenient-api.prose.test.ts +++ b/test/integration/transactions-convenient-api/transactions-convenient-api.prose.test.ts @@ -2,8 +2,13 @@ import { expect } from 'chai'; import { test } from 'mocha'; import * as sinon from 'sinon'; -import { type ClientSession, type Collection, type MongoClient } from '../../mongodb'; -import { configureFailPoint, type FailCommandFailPoint, measureDuration } from '../../tools/utils'; +import { type ClientSession, type Collection, type MongoClient, MongoError } from '../../mongodb'; +import { + clearFailPoint, + configureFailPoint, + type FailCommandFailPoint, + measureDuration +} from '../../tools/utils'; const failCommand: FailCommandFailPoint = { configureFailPoint: 'failCommand', @@ -85,3 +90,158 @@ describe('Retry Backoff is Enforced', function () { } ); }); + +describe('Retry Timeout is Enforced', function () { + // Drivers should test that withTransaction enforces a non-configurable timeout before retrying + // both commits and entire transactions. + // + // We stub performance.now() to simulate elapsed time exceeding the 120-second retry limit, + // as recommended by the spec: "This might be done by internally modifying the timeout value + // used by withTransaction with some private API or using a mock timer." + // + // Without CSOT, the original error is propagated directly. + // With CSOT, the error is wrapped in a MongoOperationTimeoutError. + + let client: MongoClient; + let collection: Collection; + let timeOffset: number; + + beforeEach(async function () { + client = this.configuration.newClient(); + collection = client.db('foo').collection('bar'); + + timeOffset = 0; + const originalNow = performance.now.bind(performance); + sinon.stub(performance, 'now').callsFake(() => originalNow() + timeOffset); + }); + + afterEach(async function () { + sinon.restore(); + await clearFailPoint(this.configuration); + await client?.close(); + }); + + // Case 1: If the callback raises an error with the TransientTransactionError label and the retry + // timeout has been exceeded, withTransaction should propagate the error to its caller. + test( + 'callback TransientTransactionError propagated when retry timeout exceeded', + { + requires: { + mongodb: '>=4.4', + topology: '!single' + } + }, + async function () { + // 1. Configure a failpoint that fails insert with TransientTransactionError. + await configureFailPoint(this.configuration, { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['insert'], + errorCode: 24, + errorLabels: ['TransientTransactionError'] + } + }); + + // 2. Run withTransaction. The callback advances the clock past the 120-second retry + // limit before the insert fails, so the timeout is detected immediately. + const { result } = await measureDuration(() => { + return client.withSession(async s => { + await s.withTransaction(async session => { + timeOffset = 120_000; + await collection.insertOne({}, { session }); + }); + }); + }); + + // 3. Assert that the error is the original TransientTransactionError (propagated directly + // in the legacy non-CSOT path). + expect(result).to.be.instanceOf(MongoError); + expect((result as MongoError).hasErrorLabel('TransientTransactionError')).to.be.true; + } + ); + + // Case 2: If committing raises an error with the UnknownTransactionCommitResult label, and the + // retry timeout has been exceeded, withTransaction should propagate the error to + // its caller. + test( + 'commit UnknownTransactionCommitResult propagated when retry timeout exceeded', + { + requires: { + mongodb: '>=4.4', + topology: '!single' + } + }, + async function () { + // 1. Configure a failpoint that fails commitTransaction with UnknownTransactionCommitResult. + await configureFailPoint(this.configuration, { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['commitTransaction'], + errorCode: 64, + errorLabels: ['UnknownTransactionCommitResult'] + } + }); + + // 2. Run withTransaction. The callback advances the clock past the 120-second retry + // limit. The insert succeeds, but the commit fails and the timeout is detected. + const { result } = await measureDuration(() => { + return client.withSession(async s => { + await s.withTransaction(async session => { + timeOffset = 120_000; + await collection.insertOne({}, { session }); + }); + }); + }); + + // 3. Assert that the error is the original commit error (propagated directly + // in the legacy non-CSOT path). + expect(result).to.be.instanceOf(MongoError); + expect((result as MongoError).hasErrorLabel('UnknownTransactionCommitResult')).to.be.true; + } + ); + + // Case 3: If committing raises an error with the TransientTransactionError label and the retry + // timeout has been exceeded, withTransaction should propagate the error to its + // caller. This case may occur if the commit was internally retried against a new primary after a + // failover and the second primary returned a NoSuchTransaction error response. + test( + 'commit TransientTransactionError propagated when retry timeout exceeded', + { + requires: { + mongodb: '>=4.4', + topology: '!single' + } + }, + async function () { + // 1. Configure a failpoint that fails commitTransaction with TransientTransactionError + // (errorCode 251 = NoSuchTransaction). + await configureFailPoint(this.configuration, { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['commitTransaction'], + errorCode: 251, + errorLabels: ['TransientTransactionError'] + } + }); + + // 2. Run withTransaction. The callback advances the clock past the 120-second retry + // limit. The insert succeeds, but the commit fails and the timeout is detected. + const { result } = await measureDuration(() => { + return client.withSession(async s => { + await s.withTransaction(async session => { + timeOffset = 120_000; + await collection.insertOne({}, { session }); + }); + }); + }); + + // 3. Assert that the error is the original commit error (propagated directly + // in the legacy non-CSOT path). + expect(result).to.be.instanceOf(MongoError); + expect((result as MongoError).hasErrorLabel('TransientTransactionError')).to.be.true; + } + ); +}); diff --git a/test/spec/client-side-operations-timeout/convenient-transactions.json b/test/spec/client-side-operations-timeout/convenient-transactions.json index f9d03429db9..3400b82ba92 100644 --- a/test/spec/client-side-operations-timeout/convenient-transactions.json +++ b/test/spec/client-side-operations-timeout/convenient-transactions.json @@ -27,7 +27,8 @@ "awaitMinPoolSizeMS": 10000, "useMultipleMongoses": false, "observeEvents": [ - "commandStartedEvent" + "commandStartedEvent", + "commandFailedEvent" ] } }, @@ -188,6 +189,11 @@ } } }, + { + "commandFailedEvent": { + "commandName": "insert" + } + }, { "commandStartedEvent": { "commandName": "abortTransaction", @@ -206,6 +212,105 @@ ] } ] + }, + { + "description": "withTransaction surfaces a timeout after exhausting transient transaction retries, retaining the last transient error as the timeout cause.", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": "alwaysOn", + "data": { + "failCommands": [ + "insert" + ], + "blockConnection": true, + "blockTimeMS": 25, + "errorCode": 24, + "errorLabels": [ + "TransientTransactionError" + ] + } + } + } + }, + { + "name": "withTransaction", + "object": "session", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection", + "arguments": { + "document": { + "_id": 1 + }, + "session": "session" + }, + "expectError": { + "isError": true + } + } + ] + }, + "expectError": { + "isTimeoutError": true + } + } + ], + "expectEvents": [ + { + "client": "client", + "ignoreExtraEvents": true, + "events": [ + { + "commandStartedEvent": { + "commandName": "insert" + } + }, + { + "commandFailedEvent": { + "commandName": "insert" + } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction" + } + }, + { + "commandFailedEvent": { + "commandName": "abortTransaction" + } + }, + { + "commandStartedEvent": { + "commandName": "insert" + } + }, + { + "commandFailedEvent": { + "commandName": "insert" + } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction" + } + }, + { + "commandFailedEvent": { + "commandName": "abortTransaction" + } + } + ] + } + ] } ] } diff --git a/test/spec/client-side-operations-timeout/convenient-transactions.yml b/test/spec/client-side-operations-timeout/convenient-transactions.yml index 55b72481dfb..8157c5e4d85 100644 --- a/test/spec/client-side-operations-timeout/convenient-transactions.yml +++ b/test/spec/client-side-operations-timeout/convenient-transactions.yml @@ -19,6 +19,7 @@ createEntities: useMultipleMongoses: false observeEvents: - commandStartedEvent + - commandFailedEvent - database: id: &database database client: *client @@ -104,9 +105,73 @@ tests: command: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } + - commandFailedEvent: + commandName: insert - commandStartedEvent: commandName: abortTransaction databaseName: admin command: abortTransaction: 1 maxTimeMS: { $$type: [ "int", "long" ] } + + # This test verifies that when withTransaction encounters transient transaction errors it does not + # throw the transient transaction error when the timeout is exceeded, but instead surfaces a timeout error after + # exhausting the retry attempts within the specified timeout. + # The timeout error thrown contains as a cause the last transient error encountered. + - description: "withTransaction surfaces a timeout after exhausting transient transaction retries, retaining the last transient error as the timeout cause." + operations: + - name: failPoint + object: testRunner + arguments: + client: *failPointClient + failPoint: + configureFailPoint: failCommand + mode: alwaysOn + data: + failCommands: ["insert"] + blockConnection: true + blockTimeMS: 25 + errorCode: 24 + errorLabels: ["TransientTransactionError"] + + - name: withTransaction + object: *session + arguments: + callback: + - name: insertOne + object: *collection + arguments: + document: { _id: 1 } + session: *session + expectError: + isError: true + expectError: + isTimeoutError: true + + # Verify that multiple insert (at least 2) attempts occurred due to TransientTransactionError retries + # The exact number of events depends on timing and retry backoff, but there should be at least: + # - 2 commandStartedEvent for insert (initial + at least one retry) + # - 2 commandFailedEvent for insert (corresponding failures) + expectEvents: + - client: *client + ignoreExtraEvents: true + events: + # First insert attempt + - commandStartedEvent: + commandName: insert + - commandFailedEvent: + commandName: insert + - commandStartedEvent: + commandName: abortTransaction + - commandFailedEvent: + commandName: abortTransaction + + # Second insert attempt (retry due to TransientTransactionError) + - commandStartedEvent: + commandName: insert + - commandFailedEvent: + commandName: insert + - commandStartedEvent: + commandName: abortTransaction + - commandFailedEvent: + commandName: abortTransaction From 65c8500f5e8f5f246b0acafbd8cada2b42f0244d Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Fri, 10 Apr 2026 13:29:06 +0200 Subject: [PATCH 2/2] update to latest spec --- src/sessions.ts | 58 ++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/src/sessions.ts b/src/sessions.ts index 09fa63c4349..ec4363b4e10 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -739,16 +739,19 @@ export class ClientSession : null; // 1. Define the following: - // 1.1 Record the current monotonic time, which will be used to enforce the timeout before later retry attempts. + // 1.1 Record the current monotonic time, which will be used to enforce the 120-second / CSOT timeout before later retry attempts. // 1.2 Set `transactionAttempt` to `0`. // 1.3 Set `TIMEOUT_MS` to be `timeoutMS` if given, otherwise MAX_TIMEOUT (120-seconds). // // The spec describes timeout checks as "elapsed time < TIMEOUT_MS" (where elapsed = now - start). // We precompute `deadline = start + TIMEOUT_MS` so each check becomes simply `now < deadline`. // - // Note 1: When TIMEOUT_MS is reached, we MUST report a timeout error wrapping the last error that - // triggered retry. With CSOT this is a MongoOperationTimeoutError; without CSOT the raw error - // is propagated directly. See makeTimeoutError() below. + // Timeout Error propagation mechanism + // When the TIMEOUT_MS (calculated in step 1.3) is reached we MUST report a timeout error wrapping the previously + // encountered error. If timeoutMS is set, then timeout error is a special type which is defined in CSOT + // specification, If timeoutMS is not set, then propagate it as timeout error if the language allows to expose the + // previously encountered error as a cause of a timeout error (see makeTimeoutError below in pseudo-code). If + // timeout error is thrown then it SHOULD copy all error label(s) from the previously encountered retriable error. const csotEnabled = !!this.timeoutContext?.csotEnabled(); const deadline = this.timeoutContext?.csotEnabled() ? processTimeMS() + this.timeoutContext.remainingTimeMS @@ -768,12 +771,16 @@ export class ClientSession ) { // 2. If `transactionAttempt` > 0: if (isRetry) { - // 2.1 Calculate backoffMS. If elapsed time + backoffMS > TIMEOUT_MS - // (i.e., now + backoff >= deadline), raise the previously encountered error (see Note 1). - // Otherwise, sleep for backoffMS. + // 2.1 If elapsed time + backoffMS > TIMEOUT_MS, then propagate the previously encountered + // error (see propagation section above). If the elapsed time of withTransaction is less + // than TIMEOUT_MS, calculate the backoffMS to be + // jitter * min(BACKOFF_INITIAL * 1.5 ** (transactionAttempt - 1), BACKOFF_MAX). + // sleep for backoffMS. const BACKOFF_INITIAL_MS = 5; const BACKOFF_MAX_MS = 500; const BACKOFF_GROWTH = 1.5; + // 2.1.1 Jitter is a random float between [0, 1), optionally including 1, depending on what is most natural + // for the given driver language. const jitter = Math.random(); const backoffMS = jitter * @@ -795,13 +802,16 @@ export class ClientSession await setTimeout(backoffMS); } - // 3. Invoke startTransaction on the session and increment transactionAttempt. + // 3. Invoke startTransaction on the session and increment transactionAttempt. If TransactionOptions were + // specified in the call to withTransaction, those MUST be used for startTransaction. Note that + // ClientSession.defaultTransactionOptions will be used in the absence of any explicit TransactionOptions. // 4. If startTransaction reported an error, propagate that error to the caller and return immediately. this.startTransaction(options); // may throw on error try { - // 5. Invoke the callback. - // 6. Control returns to withTransaction. Determine the current state and whether the callback reported an error. + // 5. Invoke the callback. Drivers MUST ensure that the ClientSession can be accessed within the callback + // (e.g. pass ClientSession as the first parameter, rely on lexical scoping). Drivers MAY pass additional + // parameters as needed (e.g. user data solicited by withTransaction). const promise = fn(this); if (!isPromiseLike(promise)) { throw new MongoInvalidArgumentError( @@ -809,6 +819,8 @@ export class ClientSession ); } + // 6. Control returns to withTransaction. Determine the current state of the ClientSession and whether the + // callback reported an error (e.g. thrown exception, error output parameter). result = await promise; // 8. If the ClientSession is in the "no transaction", "transaction aborted", or "transaction committed" @@ -843,6 +855,9 @@ export class ClientSession // 7.2 If the callback's error includes a "TransientTransactionError" label, jump back to step two. if (fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) { + if (processTimeMS() >= deadline) { + throw makeTimeoutError(lastError, csotEnabled); + } continue retryTransaction; } @@ -864,29 +879,28 @@ export class ClientSession // 10. If commitTransaction reported an error: lastError = commitError; - // If elapsed time >= TIMEOUT_MS (i.e., now >= deadline), raise a timeout error (see Note 1). - if (processTimeMS() >= deadline) { - throw makeTimeoutError(commitError, csotEnabled); - } - - // 10.1 If the error includes "UnknownTransactionCommitResult" and is not MaxTimeMSExpired - // and elapsed time < TIMEOUT_MS (guaranteed — deadline check above), jump back to step nine. - // Note: a maxTimeMS error will have the MaxTimeMSExpired code (50) and can be reported - // as a top-level error or inside writeConcernError. + // 10.1 If the commitTransaction error includes a UnknownTransactionCommitResult label and the error is not MaxTimeMSExpired if ( !isMaxTimeMSExpiredError(commitError) && commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) ) { + // 10.1.1 If the elapsed time of withTransaction exceeded TIMEOUT_MS, propagate the commitTransaction error to the caller + // of withTransaction and return immediately (see propagation section above) + if (processTimeMS() >= deadline) { + throw makeTimeoutError(commitError, csotEnabled); + } + // 10.1.2 If the elapsed time of withTransaction is less than TIMEOUT_MS, jump back to step nine. We will trust + // commitTransaction to apply a majority write concern on retry attempts (see: Majority write concern is used + // when retrying commitTransaction). continue retryCommit; } - // 10.2 If the error includes "TransientTransactionError" and elapsed time < TIMEOUT_MS - // (guaranteed — deadline check above), jump back to step two. + // 10.2 If the commitTransaction error includes a "TransientTransactionError" label, jump back to step two. if (commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) { continue retryTransaction; } - // 10.3 Otherwise, propagate the commitTransaction error (see Note 1) and return immediately. + // 10.3 Otherwise, propagate the commitTransaction error to the caller of withTransaction and return immediately. throw commitError; } }