From b1cb840783965ef09a0f871e1bdbb30e261d1a9c Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Mon, 16 Mar 2026 11:53:13 +0100 Subject: [PATCH 1/9] fix(NODE-7430): throw timeout error when `withTransaction` retries exceed deadline --- src/sessions.ts | 105 ++++++----- .../transactions-convenient-api.prose.test.ts | 168 +++++++++++++++++- .../convenient-transactions.json | 107 ++++++++++- .../convenient-transactions.yml | 65 +++++++ 4 files changed, 399 insertions(+), 46 deletions(-) diff --git a/src/sessions.ts b/src/sessions.ts index ea209b63d10..c30d5ac6f72 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -17,6 +17,7 @@ import { MongoErrorLabel, MongoExpiredSessionError, MongoInvalidArgumentError, + MongoOperationTimeoutError, MongoRuntimeError, MongoServerError, MongoTransactionError, @@ -777,14 +778,15 @@ export class ClientSession const willExceedTransactionDeadline = (this.timeoutContext?.csotEnabled() && backoffMS > this.timeoutContext.remainingTimeMS) || - processTimeMS() + backoffMS > startTime + MAX_TIMEOUT; + (!this.timeoutContext?.csotEnabled() && + processTimeMS() + backoffMS > startTime + MAX_TIMEOUT); if (willExceedTransactionDeadline) { - throw ( + throw makeWithTransactionTimeoutError( 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.` + ) ); } @@ -827,6 +829,8 @@ export class ClientSession throw fnError; } + lastError = fnError; + if ( this.transaction.state === TxnState.STARTING_TRANSACTION || this.transaction.state === TxnState.TRANSACTION_IN_PROGRESS @@ -836,14 +840,15 @@ export class ClientSession 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; - continue retryTransaction; + if (fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) { + if (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 TIMEOUT_MS, jump back to step two. + continue retryTransaction; + } else { + // 7.ii (cont.) If timeout has been exceeded, raise a timeout error wrapping the transient error. + throw makeWithTransactionTimeoutError(fnError); + } } // 7.iii If the callback's error includes a "UnknownTransactionCommitResult" label, the callback must have manually committed a transaction, @@ -865,37 +870,39 @@ export class ClientSession 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? + lastError = commitError; + + // Check if the withTransaction timeout has been exceeded. + // With CSOT: check remaining time from the timeout context. + // Without CSOT: check if we've exceeded the 120-second timeout. 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; - } + (this.timeoutContext?.csotEnabled() && this.timeoutContext.remainingTimeMS <= 0) || + (!this.timeoutContext?.csotEnabled() && processTimeMS() - startTime >= MAX_TIMEOUT); + + if (hasTimedOut) { + throw makeWithTransactionTimeoutError(commitError); + } + + /* + * 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 TIMEOUT_MS, 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 TIMEOUT_MS, jump back to step two. + continue retryTransaction; } // 10.iii Otherwise, propagate the commitTransaction error to the caller of withTransaction and return immediately. @@ -912,6 +919,18 @@ export class ClientSession } } +function makeWithTransactionTimeoutError(cause: Error): MongoOperationTimeoutError { + const timeoutError = new MongoOperationTimeoutError('Timed out during withTransaction', { + cause + }); + if (cause instanceof MongoError) { + for (const label of cause.errorLabels) { + timeoutError.addErrorLabel(label); + } + } + return timeoutError; +} + 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..beb2c65badf 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,18 @@ 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, + MongoOperationTimeoutError +} from '../../mongodb'; +import { + clearFailPoint, + configureFailPoint, + type FailCommandFailPoint, + measureDuration +} from '../../tools/utils'; const failCommand: FailCommandFailPoint = { configureFailPoint: 'failCommand', @@ -85,3 +95,157 @@ 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. + // + // Note: We use CSOT's timeoutMS to enforce a short timeout instead of blocking for the full + // 120-second retry timeout, 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." + // + // The error SHOULD be propagated as a timeout error if the language allows to expose the + // underlying error as a cause of a timeout error. + + let client: MongoClient; + let collection: Collection; + + beforeEach(async function () { + client = this.configuration.newClient({ timeoutMS: 100 }); + collection = client.db('foo').collection('bar'); + }); + + afterEach(async function () { + 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 (see Note 1) to its caller. + test( + 'callback TransientTransactionError propagated as timeout error when retry timeout exceeded', + { + requires: { + mongodb: '>=4.4', + topology: '!single' + } + }, + async function () { + // 1. Configure a failpoint that always fails insert with TransientTransactionError + // and blocks for 25ms to consume timeout budget. + await configureFailPoint(this.configuration, { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['insert'], + blockConnection: true, + blockTimeMS: 25, + errorCode: 24, + errorLabels: ['TransientTransactionError'] + } + }); + + // 2. Run withTransaction with a callback that performs an insert. + // The insert will always fail with TransientTransactionError, triggering retries + // until the timeout (timeoutMS: 100) is exceeded. + const { result } = await measureDuration(() => { + return client.withSession(async s => { + await s.withTransaction(async session => { + await collection.insertOne({}, { session }); + }); + }); + }); + + // 3. Assert that the error is a timeout error wrapping the TransientTransactionError. + expect(result).to.be.instanceOf(MongoOperationTimeoutError); + expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); + } + ); + + // Case 2: If committing raises an error with the UnknownTransactionCommitResult label, and the + // retry timeout has been exceeded, withTransaction should propagate the error (see Note 1) to + // its caller. + test( + 'commit UnknownTransactionCommitResult propagated as timeout error when retry timeout exceeded', + { + requires: { + mongodb: '>=4.4', + topology: '!single' + } + }, + async function () { + // 1. Configure a failpoint that always fails commitTransaction with + // UnknownTransactionCommitResult and blocks for 25ms to consume timeout budget. + await configureFailPoint(this.configuration, { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['commitTransaction'], + blockConnection: true, + blockTimeMS: 25, + errorCode: 64, + errorLabels: ['UnknownTransactionCommitResult'] + } + }); + + // 2. Run withTransaction with a callback that performs an insert (succeeds). + // The commit will always fail with UnknownTransactionCommitResult, triggering commit + // retries until the timeout (timeoutMS: 100) is exceeded. + const { result } = await measureDuration(() => { + return client.withSession(async s => { + await s.withTransaction(async session => { + await collection.insertOne({}, { session }); + }); + }); + }); + + // 3. Assert that the error is a timeout error. + expect(result).to.be.instanceOf(MongoOperationTimeoutError); + } + ); + + // Case 3: If committing raises an error with the TransientTransactionError label and the retry + // timeout has been exceeded, withTransaction should propagate the error (see Note 1) 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 as timeout error when retry timeout exceeded', + { + requires: { + mongodb: '>=4.4', + topology: '!single' + } + }, + async function () { + // 1. Configure a failpoint that always fails commitTransaction with + // TransientTransactionError (errorCode 251 = NoSuchTransaction) and blocks for 25ms + // to consume timeout budget. + await configureFailPoint(this.configuration, { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['commitTransaction'], + blockConnection: true, + blockTimeMS: 25, + errorCode: 251, + errorLabels: ['TransientTransactionError'] + } + }); + + // 2. Run withTransaction with a callback that performs an insert (succeeds). + // The commit will always fail with TransientTransactionError, triggering full + // transaction retries until the timeout (timeoutMS: 100) is exceeded. + const { result } = await measureDuration(() => { + return client.withSession(async s => { + await s.withTransaction(async session => { + await collection.insertOne({}, { session }); + }); + }); + }); + + // 3. Assert that the error is a timeout error wrapping the TransientTransactionError. + expect(result).to.be.instanceOf(MongoOperationTimeoutError); + expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); + } + ); +}); 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 c68eaab86f5a1076962691cd20d129a0726945c2 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Mon, 16 Mar 2026 12:37:17 +0100 Subject: [PATCH 2/9] fail "insert" instantly, so the timeout is always detected --- .../transactions-convenient-api.prose.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 beb2c65badf..ac31aa46b16 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 @@ -131,15 +131,12 @@ describe('Retry Timeout is Enforced', function () { } }, async function () { - // 1. Configure a failpoint that always fails insert with TransientTransactionError - // and blocks for 25ms to consume timeout budget. + // 1. Configure a failpoint that always fails insert with TransientTransactionError. await configureFailPoint(this.configuration, { configureFailPoint: 'failCommand', mode: 'alwaysOn', data: { failCommands: ['insert'], - blockConnection: true, - blockTimeMS: 25, errorCode: 24, errorLabels: ['TransientTransactionError'] } @@ -147,7 +144,7 @@ describe('Retry Timeout is Enforced', function () { // 2. Run withTransaction with a callback that performs an insert. // The insert will always fail with TransientTransactionError, triggering retries - // until the timeout (timeoutMS: 100) is exceeded. + // until the timeout (timeoutMS: 100) is exceeded at the backoff check. const { result } = await measureDuration(() => { return client.withSession(async s => { await s.withTransaction(async session => { From 989577644d2a34c4a262cff9eaaf8706dbf09ab7 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Mon, 16 Mar 2026 13:12:18 +0100 Subject: [PATCH 3/9] remove blocking, make failpoint returns instantly --- .../transactions-convenient-api.prose.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 ac31aa46b16..6cb39853503 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 @@ -172,14 +172,12 @@ describe('Retry Timeout is Enforced', function () { }, async function () { // 1. Configure a failpoint that always fails commitTransaction with - // UnknownTransactionCommitResult and blocks for 25ms to consume timeout budget. + // UnknownTransactionCommitResult. await configureFailPoint(this.configuration, { configureFailPoint: 'failCommand', mode: 'alwaysOn', data: { failCommands: ['commitTransaction'], - blockConnection: true, - blockTimeMS: 25, errorCode: 64, errorLabels: ['UnknownTransactionCommitResult'] } @@ -215,15 +213,12 @@ describe('Retry Timeout is Enforced', function () { }, async function () { // 1. Configure a failpoint that always fails commitTransaction with - // TransientTransactionError (errorCode 251 = NoSuchTransaction) and blocks for 25ms - // to consume timeout budget. + // TransientTransactionError (errorCode 251 = NoSuchTransaction). await configureFailPoint(this.configuration, { configureFailPoint: 'failCommand', mode: 'alwaysOn', data: { failCommands: ['commitTransaction'], - blockConnection: true, - blockTimeMS: 25, errorCode: 251, errorLabels: ['TransientTransactionError'] } From 9483038021fb5f6a3f974c0621ddafcd9768a489 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Mon, 16 Mar 2026 13:57:47 +0100 Subject: [PATCH 4/9] increase timeoutms (match with the spec tests) --- .../transactions-convenient-api.prose.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6cb39853503..d3bada59bb5 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 @@ -111,7 +111,7 @@ describe('Retry Timeout is Enforced', function () { let collection: Collection; beforeEach(async function () { - client = this.configuration.newClient({ timeoutMS: 100 }); + client = this.configuration.newClient({ timeoutMS: 500 }); collection = client.db('foo').collection('bar'); }); From 763f4873fe8495766754182f108eb838a374c791 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Mon, 16 Mar 2026 14:35:20 +0100 Subject: [PATCH 5/9] use mock performance.now to simulate timeout reliably --- .../transactions-convenient-api.prose.test.ts | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) 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 d3bada59bb5..34ccd4332cd 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 @@ -100,22 +100,28 @@ describe('Retry Timeout is Enforced', function () { // Drivers should test that withTransaction enforces a non-configurable timeout before retrying // both commits and entire transactions. // - // Note: We use CSOT's timeoutMS to enforce a short timeout instead of blocking for the full - // 120-second retry timeout, 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." + // 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." // // The error SHOULD be propagated as a timeout error if the language allows to expose the // underlying error as a cause of a timeout error. let client: MongoClient; let collection: Collection; + let timeOffset: number; beforeEach(async function () { - client = this.configuration.newClient({ timeoutMS: 500 }); + 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(); }); @@ -131,10 +137,10 @@ describe('Retry Timeout is Enforced', function () { } }, async function () { - // 1. Configure a failpoint that always fails insert with TransientTransactionError. + // 1. Configure a failpoint that fails insert with TransientTransactionError. await configureFailPoint(this.configuration, { configureFailPoint: 'failCommand', - mode: 'alwaysOn', + mode: { times: 1 }, data: { failCommands: ['insert'], errorCode: 24, @@ -142,12 +148,12 @@ describe('Retry Timeout is Enforced', function () { } }); - // 2. Run withTransaction with a callback that performs an insert. - // The insert will always fail with TransientTransactionError, triggering retries - // until the timeout (timeoutMS: 100) is exceeded at the backoff check. + // 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 }); }); }); @@ -171,11 +177,10 @@ describe('Retry Timeout is Enforced', function () { } }, async function () { - // 1. Configure a failpoint that always fails commitTransaction with - // UnknownTransactionCommitResult. + // 1. Configure a failpoint that fails commitTransaction with UnknownTransactionCommitResult. await configureFailPoint(this.configuration, { configureFailPoint: 'failCommand', - mode: 'alwaysOn', + mode: { times: 1 }, data: { failCommands: ['commitTransaction'], errorCode: 64, @@ -183,19 +188,20 @@ describe('Retry Timeout is Enforced', function () { } }); - // 2. Run withTransaction with a callback that performs an insert (succeeds). - // The commit will always fail with UnknownTransactionCommitResult, triggering commit - // retries until the timeout (timeoutMS: 100) is exceeded. + // 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 a timeout error. + // 3. Assert that the error is a timeout error wrapping the commit error. expect(result).to.be.instanceOf(MongoOperationTimeoutError); + expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); } ); @@ -212,11 +218,11 @@ describe('Retry Timeout is Enforced', function () { } }, async function () { - // 1. Configure a failpoint that always fails commitTransaction with - // TransientTransactionError (errorCode 251 = NoSuchTransaction). + // 1. Configure a failpoint that fails commitTransaction with TransientTransactionError + // (errorCode 251 = NoSuchTransaction). await configureFailPoint(this.configuration, { configureFailPoint: 'failCommand', - mode: 'alwaysOn', + mode: { times: 1 }, data: { failCommands: ['commitTransaction'], errorCode: 251, @@ -224,12 +230,12 @@ describe('Retry Timeout is Enforced', function () { } }); - // 2. Run withTransaction with a callback that performs an insert (succeeds). - // The commit will always fail with TransientTransactionError, triggering full - // transaction retries until the timeout (timeoutMS: 100) is exceeded. + // 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 }); }); }); From 74454e216d403f728369fe168b7b136e41f922d6 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Mon, 16 Mar 2026 14:45:24 +0100 Subject: [PATCH 6/9] remove Note 1 reference (copied from spec) --- .../transactions-convenient-api.prose.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 34ccd4332cd..d3f29de530a 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 @@ -127,7 +127,7 @@ describe('Retry Timeout is Enforced', function () { }); // Case 1: If the callback raises an error with the TransientTransactionError label and the retry - // timeout has been exceeded, withTransaction should propagate the error (see Note 1) to its caller. + // timeout has been exceeded, withTransaction should propagate the error to its caller. test( 'callback TransientTransactionError propagated as timeout error when retry timeout exceeded', { @@ -166,7 +166,7 @@ describe('Retry Timeout is Enforced', function () { ); // Case 2: If committing raises an error with the UnknownTransactionCommitResult label, and the - // retry timeout has been exceeded, withTransaction should propagate the error (see Note 1) to + // retry timeout has been exceeded, withTransaction should propagate the error to // its caller. test( 'commit UnknownTransactionCommitResult propagated as timeout error when retry timeout exceeded', @@ -206,7 +206,7 @@ describe('Retry Timeout is Enforced', function () { ); // Case 3: If committing raises an error with the TransientTransactionError label and the retry - // timeout has been exceeded, withTransaction should propagate the error (see Note 1) to its + // 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( From c02566c03588b5756d05e0430181cea0b9179e35 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Tue, 17 Mar 2026 09:14:17 +0100 Subject: [PATCH 7/9] test label propagation from `makeWithTransactionTimeoutError` --- .../transactions-convenient-api.prose.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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 d3f29de530a..a7a771d11d1 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 @@ -162,6 +162,8 @@ describe('Retry Timeout is Enforced', function () { // 3. Assert that the error is a timeout error wrapping the TransientTransactionError. expect(result).to.be.instanceOf(MongoOperationTimeoutError); expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); + expect((result as MongoOperationTimeoutError).hasErrorLabel('TransientTransactionError')).to.be + .true; } ); @@ -202,6 +204,9 @@ describe('Retry Timeout is Enforced', function () { // 3. Assert that the error is a timeout error wrapping the commit error. expect(result).to.be.instanceOf(MongoOperationTimeoutError); expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); + expect( + (result as MongoOperationTimeoutError).hasErrorLabel('UnknownTransactionCommitResult') + ).to.be.true; } ); @@ -244,6 +249,8 @@ describe('Retry Timeout is Enforced', function () { // 3. Assert that the error is a timeout error wrapping the TransientTransactionError. expect(result).to.be.instanceOf(MongoOperationTimeoutError); expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); + expect((result as MongoOperationTimeoutError).hasErrorLabel('TransientTransactionError')).to.be + .true; } ); }); From 7e159919afad50da82d0b81061354960b5b22915 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Tue, 17 Mar 2026 15:16:07 +0100 Subject: [PATCH 8/9] lint --- .../transactions-convenient-api.prose.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 a7a771d11d1..6eb38f6186b 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 @@ -162,8 +162,8 @@ describe('Retry Timeout is Enforced', function () { // 3. Assert that the error is a timeout error wrapping the TransientTransactionError. expect(result).to.be.instanceOf(MongoOperationTimeoutError); expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); - expect((result as MongoOperationTimeoutError).hasErrorLabel('TransientTransactionError')).to.be - .true; + expect((result as MongoOperationTimeoutError).hasErrorLabel('TransientTransactionError')).to + .be.true; } ); @@ -204,9 +204,8 @@ describe('Retry Timeout is Enforced', function () { // 3. Assert that the error is a timeout error wrapping the commit error. expect(result).to.be.instanceOf(MongoOperationTimeoutError); expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); - expect( - (result as MongoOperationTimeoutError).hasErrorLabel('UnknownTransactionCommitResult') - ).to.be.true; + expect((result as MongoOperationTimeoutError).hasErrorLabel('UnknownTransactionCommitResult')) + .to.be.true; } ); @@ -249,8 +248,8 @@ describe('Retry Timeout is Enforced', function () { // 3. Assert that the error is a timeout error wrapping the TransientTransactionError. expect(result).to.be.instanceOf(MongoOperationTimeoutError); expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); - expect((result as MongoOperationTimeoutError).hasErrorLabel('TransientTransactionError')).to.be - .true; + expect((result as MongoOperationTimeoutError).hasErrorLabel('TransientTransactionError')).to + .be.true; } ); }); From fbb911483e2f9a87a61eb7c1629bf8b0676fea6d Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Wed, 18 Mar 2026 12:34:56 +0100 Subject: [PATCH 9/9] differentiate CSOT and MAX_TIMEOUT errors --- src/sessions.ts | 61 ++++++++----------- .../transactions-convenient-api.prose.test.ts | 44 ++++++------- 2 files changed, 45 insertions(+), 60 deletions(-) diff --git a/src/sessions.ts b/src/sessions.ts index c30d5ac6f72..f89bc008c0a 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -726,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 = @@ -738,10 +738,12 @@ 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. Compute the absolute deadline for timeout enforcement. + // 1.3 Set `TIMEOUT_MS` to be `timeoutMS` if given, otherwise MAX_TIMEOUT (120-seconds). + const csotEnabled = !!this.timeoutContext?.csotEnabled(); + const deadline = this.timeoutContext?.csotEnabled() + ? processTimeMS() + this.timeoutContext.remainingTimeMS + : processTimeMS() + MAX_TIMEOUT; let committed = false; let result: T; @@ -775,18 +777,13 @@ export class ClientSession BACKOFF_MAX_MS ); - const willExceedTransactionDeadline = - (this.timeoutContext?.csotEnabled() && - backoffMS > this.timeoutContext.remainingTimeMS) || - (!this.timeoutContext?.csotEnabled() && - processTimeMS() + backoffMS > startTime + MAX_TIMEOUT); - - if (willExceedTransactionDeadline) { - throw makeWithTransactionTimeoutError( + if (processTimeMS() + backoffMS >= deadline) { + throw makeTimeoutError( lastError ?? new MongoRuntimeError( `Transaction retry did not record an error: should never occur. Please file a bug.` - ) + ), + csotEnabled ); } @@ -841,13 +838,13 @@ export class ClientSession } if (fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) { - if (this.timeoutContext?.csotEnabled() || processTimeMS() - startTime < MAX_TIMEOUT) { + if (processTimeMS() < deadline) { // 7.ii If the callback's error includes a "TransientTransactionError" label and the elapsed time of `withTransaction` // is less than TIMEOUT_MS, jump back to step two. continue retryTransaction; } else { - // 7.ii (cont.) If timeout has been exceeded, raise a timeout error wrapping the transient error. - throw makeWithTransactionTimeoutError(fnError); + // 7.ii (cont.) If timeout has been exceeded, raise the transient error (or wrap in timeout for CSOT). + throw makeTimeoutError(fnError, csotEnabled); } } @@ -872,15 +869,8 @@ export class ClientSession } catch (commitError) { lastError = commitError; - // Check if the withTransaction timeout has been exceeded. - // With CSOT: check remaining time from the timeout context. - // Without CSOT: check if we've exceeded the 120-second timeout. - const hasTimedOut = - (this.timeoutContext?.csotEnabled() && this.timeoutContext.remainingTimeMS <= 0) || - (!this.timeoutContext?.csotEnabled() && processTimeMS() - startTime >= MAX_TIMEOUT); - - if (hasTimedOut) { - throw makeWithTransactionTimeoutError(commitError); + if (processTimeMS() >= deadline) { + throw makeTimeoutError(commitError, csotEnabled); } /* @@ -919,16 +909,19 @@ export class ClientSession } } -function makeWithTransactionTimeoutError(cause: Error): MongoOperationTimeoutError { - const timeoutError = new MongoOperationTimeoutError('Timed out during withTransaction', { - cause - }); - if (cause instanceof MongoError) { - for (const label of cause.errorLabels) { - timeoutError.addErrorLabel(label); +function makeTimeoutError(cause: Error, csotEnabled: boolean): Error { + 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 timeoutError; + return cause; } const NON_DETERMINISTIC_WRITE_CONCERN_ERRORS = new Set([ 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 6eb38f6186b..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,12 +2,7 @@ import { expect } from 'chai'; import { test } from 'mocha'; import * as sinon from 'sinon'; -import { - type ClientSession, - type Collection, - type MongoClient, - MongoOperationTimeoutError -} from '../../mongodb'; +import { type ClientSession, type Collection, type MongoClient, MongoError } from '../../mongodb'; import { clearFailPoint, configureFailPoint, @@ -104,8 +99,8 @@ describe('Retry Timeout is Enforced', function () { // 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." // - // The error SHOULD be propagated as a timeout error if the language allows to expose the - // underlying error as a cause of a timeout error. + // Without CSOT, the original error is propagated directly. + // With CSOT, the error is wrapped in a MongoOperationTimeoutError. let client: MongoClient; let collection: Collection; @@ -129,7 +124,7 @@ describe('Retry Timeout is Enforced', function () { // 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 as timeout error when retry timeout exceeded', + 'callback TransientTransactionError propagated when retry timeout exceeded', { requires: { mongodb: '>=4.4', @@ -159,11 +154,10 @@ describe('Retry Timeout is Enforced', function () { }); }); - // 3. Assert that the error is a timeout error wrapping the TransientTransactionError. - expect(result).to.be.instanceOf(MongoOperationTimeoutError); - expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); - expect((result as MongoOperationTimeoutError).hasErrorLabel('TransientTransactionError')).to - .be.true; + // 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; } ); @@ -171,7 +165,7 @@ describe('Retry Timeout is Enforced', function () { // retry timeout has been exceeded, withTransaction should propagate the error to // its caller. test( - 'commit UnknownTransactionCommitResult propagated as timeout error when retry timeout exceeded', + 'commit UnknownTransactionCommitResult propagated when retry timeout exceeded', { requires: { mongodb: '>=4.4', @@ -201,11 +195,10 @@ describe('Retry Timeout is Enforced', function () { }); }); - // 3. Assert that the error is a timeout error wrapping the commit error. - expect(result).to.be.instanceOf(MongoOperationTimeoutError); - expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); - expect((result as MongoOperationTimeoutError).hasErrorLabel('UnknownTransactionCommitResult')) - .to.be.true; + // 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; } ); @@ -214,7 +207,7 @@ describe('Retry Timeout is Enforced', function () { // 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 as timeout error when retry timeout exceeded', + 'commit TransientTransactionError propagated when retry timeout exceeded', { requires: { mongodb: '>=4.4', @@ -245,11 +238,10 @@ describe('Retry Timeout is Enforced', function () { }); }); - // 3. Assert that the error is a timeout error wrapping the TransientTransactionError. - expect(result).to.be.instanceOf(MongoOperationTimeoutError); - expect((result as MongoOperationTimeoutError).cause).to.be.an('error'); - expect((result as MongoOperationTimeoutError).hasErrorLabel('TransientTransactionError')).to - .be.true; + // 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; } ); });