Skip to content

Commit cc6856f

Browse files
committed
fix(NODE-7430): throw timeout error when withTransaction retries exceed deadline
1 parent ac98f4a commit cc6856f

4 files changed

Lines changed: 423 additions & 85 deletions

File tree

src/sessions.ts

Lines changed: 90 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
915923
const NON_DETERMINISTIC_WRITE_CONCERN_ERRORS = new Set([
916924
'CannotSatisfyWriteConcern',
917925
'UnknownReplWriteConcern',

0 commit comments

Comments
 (0)