@@ -17,6 +17,7 @@ import {
1717 MongoErrorLabel ,
1818 MongoExpiredSessionError ,
1919 MongoInvalidArgumentError ,
20+ MongoOperationTimeoutError ,
2021 MongoRuntimeError ,
2122 MongoServerError ,
2223 MongoTransactionError ,
@@ -725,7 +726,7 @@ export class ClientSession
725726 timeoutMS ?: number ;
726727 }
727728 ) : Promise < T > {
728- const MAX_TIMEOUT = 120000 ;
729+ const MAX_TIMEOUT = 120_000 ;
729730
730731 const timeoutMS = options ?. timeoutMS ?? this . timeoutMS ?? null ;
731732 this . timeoutContext =
@@ -737,10 +738,21 @@ export class ClientSession
737738 } )
738739 : null ;
739740
740- // 1. Record the current monotonic time, which will be used to enforce the 120-second timeout before later retry attempts.
741- const startTime = this . timeoutContext ?. csotEnabled ( ) // This is strictly to appease TS. We must narrow the context to a CSOT context before accessing `.start`.
742- ? this . timeoutContext . start
743- : processTimeMS ( ) ;
741+ // 1. Define the following:
742+ // 1.1 Record the current monotonic time, which will be used to enforce the timeout before later retry attempts.
743+ // 1.2 Set `transactionAttempt` to `0`.
744+ // 1.3 Set `TIMEOUT_MS` to be `timeoutMS` if given, otherwise MAX_TIMEOUT (120-seconds).
745+ //
746+ // The spec describes timeout checks as "elapsed time < TIMEOUT_MS" (where elapsed = now - start).
747+ // We precompute `deadline = start + TIMEOUT_MS` so each check becomes simply `now < deadline`.
748+ //
749+ // Note 1: When TIMEOUT_MS is reached, we MUST report a timeout error wrapping the last error that
750+ // triggered retry. With CSOT this is a MongoOperationTimeoutError; without CSOT the raw error
751+ // is propagated directly. See makeTimeoutError() below.
752+ const csotEnabled = ! ! this . timeoutContext ?. csotEnabled ( ) ;
753+ const deadline = this . timeoutContext ?. csotEnabled ( )
754+ ? processTimeMS ( ) + this . timeoutContext . remainingTimeMS
755+ : processTimeMS ( ) + MAX_TIMEOUT ;
744756
745757 let committed = false ;
746758 let result : T ;
@@ -749,20 +761,16 @@ export class ClientSession
749761
750762 try {
751763 retryTransaction: for (
752- // 2. Set `transactionAttempt` to `0`.
764+ // 1.2 Set `transactionAttempt` to `0`.
753765 let transactionAttempt = 0 , isRetry = false ;
754766 ! committed ;
755767 ++ transactionAttempt , isRetry = transactionAttempt > 0
756768 ) {
757769 // 2. If `transactionAttempt` > 0:
758770 if ( isRetry ) {
759- // 2.i If elapsed time + `backoffMS` > `TIMEOUT_MS`, then raise the previously encountered error. If the elapsed time of
760- // `withTransaction` is less than TIMEOUT_MS, calculate the backoffMS to be
761- // `jitter * min(BACKOFF_INITIAL * 1.5 ** (transactionAttempt - 1), BACKOFF_MAX)`. sleep for `backoffMS`.
762- // 2.i.i jitter is a random float between \[0, 1)
763- // 2.i.ii `transactionAttempt` is the variable defined in step 1.
764- // 2.i.iii `BACKOFF_INITIAL` is 5ms
765- // 2.i.iv `BACKOFF_MAX` is 500ms
771+ // 2.1 Calculate backoffMS. If elapsed time + backoffMS > TIMEOUT_MS
772+ // (i.e., now + backoff >= deadline), raise the previously encountered error (see Note 1).
773+ // Otherwise, sleep for backoffMS.
766774 const BACKOFF_INITIAL_MS = 5 ;
767775 const BACKOFF_MAX_MS = 500 ;
768776 const BACKOFF_GROWTH = 1.5 ;
@@ -774,30 +782,26 @@ export class ClientSession
774782 BACKOFF_MAX_MS
775783 ) ;
776784
777- const willExceedTransactionDeadline =
778- ( this . timeoutContext ?. csotEnabled ( ) &&
779- backoffMS > this . timeoutContext . remainingTimeMS ) ||
780- processTimeMS ( ) + backoffMS > startTime + MAX_TIMEOUT ;
781-
782- if ( willExceedTransactionDeadline ) {
783- throw (
785+ if ( processTimeMS ( ) + backoffMS >= deadline ) {
786+ throw makeTimeoutError (
784787 lastError ??
785- new MongoRuntimeError (
786- `Transaction retry did not record an error: should never occur. Please file a bug.`
787- )
788+ new MongoRuntimeError (
789+ `Transaction retry did not record an error: should never occur. Please file a bug.`
790+ ) ,
791+ csotEnabled
788792 ) ;
789793 }
790794
791795 await setTimeout ( backoffMS ) ;
792796 }
793797
794- // 3. Invoke startTransaction on the session
795- // 4. If ` startTransaction` reported an error, propagate that error to the caller of `withTransaction` and return immediately.
798+ // 3. Invoke startTransaction on the session and increment transactionAttempt.
799+ // 4. If startTransaction reported an error, propagate that error to the caller and return immediately.
796800 this . startTransaction ( options ) ; // may throw on error
797801
798802 try {
799803 // 5. Invoke the callback.
800- // 6. Control returns to withTransaction. (continued below)
804+ // 6. Control returns to withTransaction. Determine the current state and whether the callback reported an error.
801805 const promise = fn ( this ) ;
802806 if ( ! isPromiseLike ( promise ) ) {
803807 throw new MongoInvalidArgumentError (
@@ -807,17 +811,16 @@ export class ClientSession
807811
808812 result = await promise ;
809813
810- // 6. (cont.) Determine the current state of the ClientSession (continued below)
814+ // 8. If the ClientSession is in the "no transaction", "transaction aborted", or "transaction committed"
815+ // state, assume the callback intentionally aborted or committed the transaction and return immediately.
816+ // Drivers MAY allow the callback to return a value to be propagated as the return value of withTransaction.
811817 if (
812818 this . transaction . state === TxnState . NO_TRANSACTION ||
813819 this . transaction . state === TxnState . TRANSACTION_COMMITTED ||
814820 this . transaction . state === TxnState . TRANSACTION_ABORTED
815821 ) {
816- // 8. If the ClientSession is in the "no transaction", "transaction aborted", or "transaction committed" state,
817- // assume the callback intentionally aborted or committed the transaction and return immediately.
818822 return result ;
819823 }
820- // 5. (cont.) and whether the callback reported an error
821824 // 7. If the callback reported an error:
822825 } catch ( fnError ) {
823826 if ( ! ( fnError instanceof MongoError ) || fnError instanceof MongoInvalidArgumentError ) {
@@ -827,83 +830,69 @@ export class ClientSession
827830 throw fnError ;
828831 }
829832
833+ lastError = fnError ;
834+
835+ // 7.1 If the ClientSession is in the "starting transaction" or "transaction in progress"
836+ // state, invoke abortTransaction on the session.
830837 if (
831838 this . transaction . state === TxnState . STARTING_TRANSACTION ||
832839 this . transaction . state === TxnState . TRANSACTION_IN_PROGRESS
833840 ) {
834- // 7.i If the ClientSession is in the "starting transaction" or "transaction in progress" state,
835- // invoke abortTransaction on the session
836841 await this . abortTransaction ( ) ;
837842 }
838843
839- if (
840- fnError . hasErrorLabel ( MongoErrorLabel . TransientTransactionError ) &&
841- ( this . timeoutContext ?. csotEnabled ( ) || processTimeMS ( ) - startTime < MAX_TIMEOUT )
842- ) {
843- // 7.ii If the callback's error includes a "TransientTransactionError" label and the elapsed time of `withTransaction`
844- // is less than 120 seconds, jump back to step two.
845- lastError = fnError ;
844+ // 7.2 If the callback's error includes a "TransientTransactionError" label, jump back to step two.
845+ if ( fnError . hasErrorLabel ( MongoErrorLabel . TransientTransactionError ) ) {
846846 continue retryTransaction;
847847 }
848848
849- // 7.iii If the callback's error includes a "UnknownTransactionCommitResult" label, the callback must have manually committed a transaction,
850- // propagate the callback's error to the caller of withTransaction and return immediately.
851- // The 7.iii check is redundant with 6.iv , so we don't write code for it
852- // 7.iv Otherwise, propagate the callback's error to the caller of withTransaction and return immediately.
849+ // 7.3 If the callback's error includes a "UnknownTransactionCommitResult" label, the callback
850+ // must have manually committed a transaction, propagate the error and return immediately.
851+ // (This check is redundant with step 8 , so we don't write code for it.)
852+ // 7.4 Otherwise, propagate the callback's error (see Note 1) and return immediately.
853853 throw fnError ;
854854 }
855855
856+ // 9. Invoke commitTransaction on the session.
857+ // We will rely on ClientSession.commitTransaction() to apply a majority write concern
858+ // if commitTransaction is being retried (see: DRIVERS-601).
856859 retryCommit: while ( ! committed ) {
857860 try {
858- /*
859- * We will rely on ClientSession.commitTransaction() to
860- * apply a majority write concern if commitTransaction is
861- * being retried (see: DRIVERS-601)
862- */
863- // 9. Invoke commitTransaction on the session.
864861 await this . commitTransaction ( ) ;
865862 committed = true ;
866- // 10. If commitTransaction reported an error:
867863 } catch ( commitError ) {
868- // If CSOT is enabled, we repeatedly retry until timeoutMS expires. This is enforced by providing a
869- // timeoutContext to each async API, which know how to cancel themselves (i.e., the next retry will
870- // abort the withTransaction call).
871- // If CSOT is not enabled, do we still have time remaining or have we timed out?
872- const hasTimedOut =
873- ! this . timeoutContext ?. csotEnabled ( ) && processTimeMS ( ) - startTime >= MAX_TIMEOUT ;
874-
875- if ( ! hasTimedOut ) {
876- /*
877- * Note: a maxTimeMS error will have the MaxTimeMSExpired
878- * code (50) and can be reported as a top-level error or
879- * inside writeConcernError, ex.
880- * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' }
881- * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } }
882- */
883- if (
884- ! isMaxTimeMSExpiredError ( commitError ) &&
885- commitError . hasErrorLabel ( MongoErrorLabel . UnknownTransactionCommitResult )
886- ) {
887- // 10.i If the `commitTransaction` error includes a "UnknownTransactionCommitResult" label and the error is not
888- // MaxTimeMSExpired and the elapsed time of `withTransaction` is less than 120 seconds, jump back to step eight.
889- continue retryCommit;
890- }
891-
892- if ( commitError . hasErrorLabel ( MongoErrorLabel . TransientTransactionError ) ) {
893- // 10.ii If the commitTransaction error includes a "TransientTransactionError" label
894- // and the elapsed time of withTransaction is less than 120 seconds, jump back to step two.
895- lastError = commitError ;
896-
897- continue retryTransaction;
898- }
864+ // 10. If commitTransaction reported an error:
865+ lastError = commitError ;
866+
867+ // If elapsed time >= TIMEOUT_MS (i.e., now >= deadline), raise a timeout error (see Note 1).
868+ if ( processTimeMS ( ) >= deadline ) {
869+ throw makeTimeoutError ( commitError , csotEnabled ) ;
870+ }
871+
872+ // 10.1 If the error includes "UnknownTransactionCommitResult" and is not MaxTimeMSExpired
873+ // and elapsed time < TIMEOUT_MS (guaranteed — deadline check above), jump back to step nine.
874+ // Note: a maxTimeMS error will have the MaxTimeMSExpired code (50) and can be reported
875+ // as a top-level error or inside writeConcernError.
876+ if (
877+ ! isMaxTimeMSExpiredError ( commitError ) &&
878+ commitError . hasErrorLabel ( MongoErrorLabel . UnknownTransactionCommitResult )
879+ ) {
880+ continue retryCommit;
899881 }
900882
901- // 10.iii Otherwise, propagate the commitTransaction error to the caller of withTransaction and return immediately.
883+ // 10.2 If the error includes "TransientTransactionError" and elapsed time < TIMEOUT_MS
884+ // (guaranteed — deadline check above), jump back to step two.
885+ if ( commitError . hasErrorLabel ( MongoErrorLabel . TransientTransactionError ) ) {
886+ continue retryTransaction;
887+ }
888+
889+ // 10.3 Otherwise, propagate the commitTransaction error (see Note 1) and return immediately.
902890 throw commitError ;
903891 }
904892 }
905893 }
906894
895+ // 11. The transaction was committed successfully. Return immediately.
907896 // @ts -expect-error Result is always defined if we reach here, the for-loop above convinces TS it is not.
908897 return result ;
909898 } finally {
@@ -912,6 +901,25 @@ export class ClientSession
912901 }
913902}
914903
904+ function makeTimeoutError ( cause : Error , csotEnabled : boolean ) : Error {
905+ // Async APIs know how to cancel themselves and might return CSOT error
906+ if ( cause instanceof MongoOperationTimeoutError ) {
907+ return cause ;
908+ }
909+ if ( csotEnabled ) {
910+ const timeoutError = new MongoOperationTimeoutError ( 'Timed out during withTransaction' , {
911+ cause
912+ } ) ;
913+ if ( cause instanceof MongoError ) {
914+ for ( const label of cause . errorLabels ) {
915+ timeoutError . addErrorLabel ( label ) ;
916+ }
917+ }
918+ return timeoutError ;
919+ }
920+ return cause ;
921+ }
922+
915923const NON_DETERMINISTIC_WRITE_CONCERN_ERRORS = new Set ( [
916924 'CannotSatisfyWriteConcern' ,
917925 'UnknownReplWriteConcern' ,
0 commit comments