Skip to content

Commit b27507d

Browse files
misc cleanup to improve readability
1 parent c990750 commit b27507d

File tree

2 files changed

+155
-29
lines changed

2 files changed

+155
-29
lines changed

src/sessions.ts

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { setTimeout } from 'timers/promises';
2+
13
import { Binary, type Document, Long, type Timestamp } from './bson';
24
import type { CommandOptions, Connection } from './cmap/connection';
35
import { 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;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { expect } from 'chai';
2+
import { test } from 'mocha';
3+
import * as sinon from 'sinon';
4+
5+
import { type ClientSession, type Collection, type MongoClient } from '../../../src';
6+
import { configureFailPoint, type FailCommandFailPoint, measureDuration } from '../../tools/utils';
7+
8+
const failCommand: FailCommandFailPoint = {
9+
configureFailPoint: 'failCommand',
10+
mode: {
11+
times: 13
12+
},
13+
data: {
14+
failCommands: ['commitTransaction'],
15+
errorCode: 251 // no such transaction
16+
}
17+
};
18+
19+
describe('Retry Backoff is Enforced', function () {
20+
// 1. let client be a MongoClient
21+
let client: MongoClient;
22+
23+
// 2. let coll be a collection
24+
let collection: Collection;
25+
26+
beforeEach(async function () {
27+
client = this.configuration.newClient();
28+
collection = client.db('foo').collection('bar');
29+
});
30+
31+
afterEach(async function () {
32+
sinon.restore();
33+
await client?.close();
34+
});
35+
36+
test(
37+
'works',
38+
{
39+
requires: {
40+
mongodb: '>=4.4', // failCommand
41+
topology: '!single' // transactions can't run on standalone servers
42+
}
43+
},
44+
async function () {
45+
const randomStub = sinon.stub(Math, 'random');
46+
47+
// 3.i Configure the random number generator used for jitter to always return 0
48+
randomStub.returns(0);
49+
50+
// 3.ii Configure a fail point that forces 13 retries
51+
await configureFailPoint(this.configuration, failCommand);
52+
53+
// 3.iii
54+
const callback = async (s: ClientSession) => {
55+
await collection.insertOne({}, { session: s });
56+
};
57+
58+
// 3.iv Let no_backoff_time be the duration of the withTransaction API call
59+
const { duration: noBackoffTime } = await measureDuration(() => {
60+
return client.withSession(async s => {
61+
await s.withTransaction(callback);
62+
});
63+
});
64+
65+
// 4.i Configure the random number generator used for jitter to always return 1.
66+
randomStub.returns(1);
67+
68+
// 4.ii Configure a fail point that forces 13 retries like in step 3.2.
69+
await configureFailPoint(this.configuration, failCommand);
70+
71+
// 4.iii Use the same callback defined in 3.3.
72+
// 4.iv Let with_backoff_time be the duration of the withTransaction API call
73+
const { duration: fullBackoffDuration } = await measureDuration(() => {
74+
return client.withSession(async s => {
75+
await s.withTransaction(callback);
76+
});
77+
});
78+
79+
// 5. Compare the two time between the two runs.
80+
// The sum of 13 backoffs is roughly 2.2 seconds. There is a 1-second window to account for potential variance between the two runs.
81+
expect(fullBackoffDuration).to.be.within(
82+
noBackoffTime + 2200 - 1000,
83+
noBackoffTime + 2200 + 1000
84+
);
85+
}
86+
);
87+
});

0 commit comments

Comments
 (0)