1+ import { setTimeout } from 'timers/promises' ;
2+
13import { Binary , type Document , Long , type Timestamp } from './bson' ;
24import type { CommandOptions , Connection } from './cmap/connection' ;
35import { ConnectionPoolMetrics } from './cmap/metrics' ;
@@ -732,10 +734,37 @@ export class ClientSession
732734 : processTimeMS ( ) ;
733735
734736 let committed = false ;
735- let result : any ;
737+ let result : T ;
738+
739+ let lastError : Error | null = null ;
736740
737741 try {
738- while ( ! committed ) {
742+ retryTransaction: for ( let attempt = 0 , isRetry = attempt > 0 ; ! committed ; ++ attempt ) {
743+ if ( isRetry ) {
744+ const BACKOFF_INITIAL_MS = 5 ;
745+ const BACKOFF_MAX_MS = 500 ;
746+ const BACKOFF_GROWTH = 1.5 ;
747+ const jitter = Math . random ( ) ;
748+ const backoffMS =
749+ jitter * Math . min ( BACKOFF_INITIAL_MS * BACKOFF_GROWTH ** attempt , BACKOFF_MAX_MS ) ;
750+
751+ const willExceedTransactionDeadline =
752+ ( this . timeoutContext ?. csotEnabled ( ) &&
753+ backoffMS > this . timeoutContext . remainingTimeMS ) ||
754+ processTimeMS ( ) + backoffMS > startTime + MAX_TIMEOUT ;
755+
756+ if ( willExceedTransactionDeadline ) {
757+ throw (
758+ lastError ??
759+ new MongoRuntimeError (
760+ `Transaction retry did not record an error: should never occur. Please file a bug.`
761+ )
762+ ) ;
763+ }
764+
765+ await setTimeout ( backoffMS ) ;
766+ }
767+
739768 // 2. Invoke startTransaction on the session
740769 // 3. If `startTransaction` reported an error, propagate that error to the caller of `withTransaction` and return immediately.
741770 this . startTransaction ( options ) ; // may throw on error
@@ -783,11 +812,12 @@ export class ClientSession
783812
784813 if (
785814 fnError . hasErrorLabel ( MongoErrorLabel . TransientTransactionError ) &&
786- ( this . timeoutContext != null || processTimeMS ( ) - startTime < MAX_TIMEOUT )
815+ ( this . timeoutContext ?. csotEnabled ( ) || processTimeMS ( ) - startTime < MAX_TIMEOUT )
787816 ) {
788817 // 6.ii If the callback's error includes a "TransientTransactionError" label and the elapsed time of `withTransaction`
789818 // is less than 120 seconds, jump back to step two.
790- continue ;
819+ lastError = fnError ;
820+ continue retryTransaction;
791821 }
792822
793823 // 6.iii If the callback's error includes a "UnknownTransactionCommitResult" label, the callback must have manually committed a transaction,
@@ -797,7 +827,7 @@ export class ClientSession
797827 throw fnError ;
798828 }
799829
800- while ( ! committed ) {
830+ retryCommit: while ( ! committed ) {
801831 try {
802832 /*
803833 * We will rely on ClientSession.commitTransaction() to
@@ -809,37 +839,46 @@ export class ClientSession
809839 committed = true ;
810840 // 9. If commitTransaction reported an error:
811841 } catch ( commitError ) {
812- /*
813- * Note: a maxTimeMS error will have the MaxTimeMSExpired
814- * code (50) and can be reported as a top-level error or
815- * inside writeConcernError, ex.
816- * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' }
817- * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } }
818- */
819- if (
820- ! isMaxTimeMSExpiredError ( commitError ) &&
821- commitError . hasErrorLabel ( MongoErrorLabel . UnknownTransactionCommitResult ) &&
822- ( this . timeoutContext != null || processTimeMS ( ) - startTime < MAX_TIMEOUT )
823- ) {
824- // 9.i If the `commitTransaction` error includes a "UnknownTransactionCommitResult" label and the error is not
825- // MaxTimeMSExpired and the elapsed time of `withTransaction` is less than 120 seconds, jump back to step eight.
826- continue ;
827- }
828-
829- if (
830- commitError . hasErrorLabel ( MongoErrorLabel . TransientTransactionError ) &&
831- ( this . timeoutContext != null || processTimeMS ( ) - startTime < MAX_TIMEOUT )
832- ) {
833- // 9.ii If the commitTransaction error includes a "TransientTransactionError" label
834- // and the elapsed time of withTransaction is less than 120 seconds, jump back to step two.
835- break ;
842+ // If CSOT is enabled, we repeatedly retry until timeoutMS expires. This is enforced by providing a
843+ // timeoutContext to each async API, which know how to cancel themselves (i.e., the next retry will
844+ // abort the withTransaction call).
845+ // If CSOT is not enabled, do we still have time remaining or have we timed out?
846+ const hasTimedOut =
847+ ! this . timeoutContext ?. csotEnabled ( ) && processTimeMS ( ) - startTime >= MAX_TIMEOUT ;
848+
849+ if ( ! hasTimedOut ) {
850+ /*
851+ * Note: a maxTimeMS error will have the MaxTimeMSExpired
852+ * code (50) and can be reported as a top-level error or
853+ * inside writeConcernError, ex.
854+ * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' }
855+ * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } }
856+ */
857+ if (
858+ ! isMaxTimeMSExpiredError ( commitError ) &&
859+ commitError . hasErrorLabel ( MongoErrorLabel . UnknownTransactionCommitResult )
860+ ) {
861+ // 9.i If the `commitTransaction` error includes a "UnknownTransactionCommitResult" label and the error is not
862+ // MaxTimeMSExpired and the elapsed time of `withTransaction` is less than 120 seconds, jump back to step eight.
863+ continue retryCommit;
864+ }
865+
866+ if ( commitError . hasErrorLabel ( MongoErrorLabel . TransientTransactionError ) ) {
867+ // 9.ii If the commitTransaction error includes a "TransientTransactionError" label
868+ // and the elapsed time of withTransaction is less than 120 seconds, jump back to step two.
869+ lastError = commitError ;
870+
871+ continue retryTransaction;
872+ }
836873 }
837874
838875 // 9.iii Otherwise, propagate the commitTransaction error to the caller of withTransaction and return immediately.
839876 throw commitError ;
840877 }
841878 }
842879 }
880+
881+ // @ts -expect-error Result is always defined if we reach here, the for-loop above convinces TS it is not.
843882 return result ;
844883 } finally {
845884 this . timeoutContext = null ;
0 commit comments