diff --git a/source/client-side-operations-timeout/tests/convenient-transactions.json b/source/client-side-operations-timeout/tests/convenient-transactions.json index f9d03429db..3400b82ba9 100644 --- a/source/client-side-operations-timeout/tests/convenient-transactions.json +++ b/source/client-side-operations-timeout/tests/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/source/client-side-operations-timeout/tests/convenient-transactions.yml b/source/client-side-operations-timeout/tests/convenient-transactions.yml index 55b72481df..8157c5e4d8 100644 --- a/source/client-side-operations-timeout/tests/convenient-transactions.yml +++ b/source/client-side-operations-timeout/tests/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 diff --git a/source/transactions-convenient-api/tests/README.md b/source/transactions-convenient-api/tests/README.md index 834ab47a25..bd9fd225df 100644 --- a/source/transactions-convenient-api/tests/README.md +++ b/source/transactions-convenient-api/tests/README.md @@ -30,17 +30,23 @@ Drivers should test that `withTransaction` enforces a non-configurable timeout b transactions. Specifically, three cases should be checked: - 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. + `withTransaction` should propagate the error (see Note 1 below) to its caller. - If committing raises an error with the UnknownTransactionCommitResult label, and the retry timeout has been exceeded, - `withTransaction` should propagate the error to its caller. + `withTransaction` should propagate the error (see Note 1 below) to its caller. - 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. + `withTransaction` should propagate the error (see Note 1 below) 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. If possible, drivers should implement these tests without requiring the test runner to block for the full duration of the retry timeout. This might be done by internally modifying the timeout value used by `withTransaction` with some private API or using a mock timer. +______________________________________________________________________ + +**Note 1:** 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. + ### Retry Backoff is Enforced Drivers should test that retries within `withTransaction` do not occur immediately. @@ -106,6 +112,8 @@ Drivers should test that retries within `withTransaction` do not occur immediate ## Changelog +- 2026-02-17: Clarify expected error when timeout is reached + [DRIVERS-3391](https://jira.mongodb.org/browse/DRIVERS-3391). - 2026-01-07: Fixed Retry Backoff is Enforced test accordingly to the updated spec. - 2025-11-18: Added Backoff test. - 2024-09-06: Migrated from reStructuredText to Markdown. diff --git a/source/transactions-convenient-api/transactions-convenient-api.md b/source/transactions-convenient-api/transactions-convenient-api.md index 025fe4a939..6d1baff47d 100644 --- a/source/transactions-convenient-api/transactions-convenient-api.md +++ b/source/transactions-convenient-api/transactions-convenient-api.md @@ -123,8 +123,8 @@ This method should perform the following sequence of actions: 2. If `transactionAttempt` > 0: - 1. 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 + 1. If elapsed time + `backoffMS` > `TIMEOUT_MS`, then raise the previously encountered error (see Note 1 below). 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`. 1. jitter is a random float between \[0, 1) @@ -162,7 +162,8 @@ This method should perform the following sequence of actions: committed a transaction, propagate the callback's error to the caller of `withTransaction` and return immediately. - 4. Otherwise, propagate the callback's error to the caller of `withTransaction` and return immediately. + 4. Otherwise, propagate the callback's error (see Note 1 below) to the caller of `withTransaction` and return + immediately. 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. @@ -178,10 +179,21 @@ This method should perform the following sequence of actions: 2. If the `commitTransaction` error includes a "TransientTransactionError" label, jump back to step two. - 3. Otherwise, propagate the `commitTransaction` error to the caller of `withTransaction` and return immediately. + 3. Otherwise, propagate the `commitTransaction` error (see Note 1 below) to the caller of `withTransaction` and + return immediately. 11. The transaction was committed successfully. Return immediately. +______________________________________________________________________ + +**Note 1:** When the `TIMEOUT_MS` (calculated in step [1.3](#sequence-of-actions)) is reached we MUST report a timeout +error wrapping the last error that was encountered which triggered the retry behavior. If `timeoutMS` is set, then +timeout error is a special type which is defined in CSOT +[specification](https://github.com/mongodb/specifications/blob/master/source/client-side-operations-timeout/client-side-operations-timeout.md#errors) +, If `timeoutMS` is not set, then propagate it as timeout error if the language allows to expose the underlying error as +a cause of a timeout error (see `makeTimeoutError` below in [pseudo-code](#pseudo-code)). If timeout error is thrown +then it SHOULD expose error label(s) from the transient error. + ##### Pseudo-code This method can be expressed by the following pseudo-code: @@ -203,7 +215,7 @@ withTransaction(callback, options) { BACKOFF_MAX); if (Date.now() + backoff - startTime >= timeout) { - throw lastError; + throw makeTimeoutError(lastError); } sleep(backoff); } @@ -220,9 +232,12 @@ withTransaction(callback, options) { this.abortTransaction(); } - if (error.hasErrorLabel("TransientTransactionError") && - Date.now() - startTime < timeout) { - continue retryTransaction; + if (error.hasErrorLabel("TransientTransactionError")) { + if (Date.now() - startTime < timeout) { + continue retryTransaction; + } else { + throw makeTimeoutError(error) + } } throw error; @@ -247,15 +262,16 @@ withTransaction(callback, options) { * {ok:0, code: 50, codeName: "MaxTimeMSExpired"} * {ok:1, writeConcernError: {code: 50, codeName: "MaxTimeMSExpired"}} */ + lastError = error; + if (Date.now() - startTime >= timeout) { + throw makeTimeoutError(error); + } if (!isMaxTimeMSExpiredError(error) && - error.hasErrorLabel("UnknownTransactionCommitResult") && - Date.now() - startTime < timeout) { + error.hasErrorLabel("UnknownTransactionCommitResult")) { continue retryCommit; } - if (error.hasErrorLabel("TransientTransactionError") && - Date.now() - startTime < timeout) { - lastError = error; + if (error.hasErrorLabel("TransientTransactionError")) { continue retryTransaction; } @@ -266,6 +282,10 @@ withTransaction(callback, options) { break; // Transaction was successful } } + +function makeTimeoutError(error) { + return getCSOTTimeoutIfSet() != null ? createCSOTMongoTimeoutException(error) : createLegacyMongoTimeoutException(error); +} ``` ### ClientSession must provide access to a MongoClient @@ -419,6 +439,9 @@ provides an implementation of a technique already described in the MongoDB 4.0 d ## Changelog +- 2026-02-17: Clarify expected error when timeout is reached + [DRIVERS-3391](https://jira.mongodb.org/browse/DRIVERS-3391). + - 2025-11-20: withTransaction applies exponential backoff when retrying. - 2024-09-06: Migrated from reStructuredText to Markdown.