Skip to content
73 changes: 50 additions & 23 deletions src/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { setTimeout } from 'timers/promises';

import { Binary, type Document, Long, type Timestamp } from './bson';
import type { CommandOptions, Connection } from './cmap/connection';
import { ConnectionPoolMetrics } from './cmap/metrics';
Expand Down Expand Up @@ -729,10 +731,10 @@ export class ClientSession
const startTime = this.timeoutContext?.csotEnabled() ? this.timeoutContext.start : now();

let committed = false;
let result: any;
let result: T;

try {
while (!committed) {
for (let retry = 0; !committed; ++retry) {
this.startTransaction(options); // may throw on error

try {
Expand Down Expand Up @@ -768,7 +770,7 @@ export class ClientSession

if (
fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) &&
(this.timeoutContext != null || now() - startTime < MAX_TIMEOUT)
(this.timeoutContext?.csotEnabled() || now() - startTime < MAX_TIMEOUT)
) {
continue;
}
Expand All @@ -786,32 +788,57 @@ export class ClientSession
await this.commitTransaction();
committed = true;
} catch (commitError) {
/*
* Note: a maxTimeMS error will have the MaxTimeMSExpired
* code (50) and can be reported as a top-level error or
* inside writeConcernError, ex.
* { ok:0, code: 50, codeName: 'MaxTimeMSExpired' }
* { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } }
*/
if (
!isMaxTimeMSExpiredError(commitError) &&
commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) &&
(this.timeoutContext != null || now() - startTime < MAX_TIMEOUT)
) {
continue;
}

if (
commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) &&
(this.timeoutContext != null || now() - startTime < MAX_TIMEOUT)
) {
break;
// If CSOT is enabled, we repeatedly retry until timeoutMS expires. This is enforced by providing a
// timeoutContext to each async API, which know how to cancel themselves (i.e., the next retry will
// abort the withTransaction call).
// If CSOT is not enabled, do we still have time remaining or have we timed out?
const hasTimedOut =
!this.timeoutContext?.csotEnabled() && now() - startTime >= MAX_TIMEOUT;

if (!hasTimedOut) {
if (
!isMaxTimeMSExpiredError(commitError) &&
commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult)
) {
/*
* Note: a maxTimeMS error will have the MaxTimeMSExpired
* code (50) and can be reported as a top-level error or
* inside writeConcernError, ex.
* { ok:0, code: 50, codeName: 'MaxTimeMSExpired' }
* { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } }
*/
continue;
}

if (commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) {
const BACKOFF_INITIAL_MS = 5;
const BACKOFF_MAX_MS = 500;
const BACKOFF_GROWTH = 1.5;
const jitter = Math.random();
const backoffMS =
jitter * Math.min(BACKOFF_INITIAL_MS * BACKOFF_GROWTH ** retry, BACKOFF_MAX_MS);

const willExceedTransactionDeadline =
(this.timeoutContext?.csotEnabled() &&
backoffMS > this.timeoutContext.remainingTimeMS) ||
now() + backoffMS > startTime + MAX_TIMEOUT;

if (willExceedTransactionDeadline) {
break;
}

await setTimeout(backoffMS);

break;
}
}

throw commitError;
}
}
}

// @ts-expect-error Result is always defined if we reach here, the for-loop above convinces TS it is not.
return result;
} finally {
this.timeoutContext = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect } from 'chai';
import { test } from 'mocha';
import * as sinon from 'sinon';

import { type ClientSession, type Collection, type MongoClient } from '../../../src';
import { configureFailPoint, type FailCommandFailPoint, measureDuration } from '../../tools/utils';

const failCommand: FailCommandFailPoint = {
configureFailPoint: 'failCommand',
mode: {
times: 13
},
data: {
failCommands: ['commitTransaction'],
errorCode: 251 // no such transaction
}
};

describe('Retry Backoff is Enforced', function () {
// 1. let client be a MongoClient
let client: MongoClient;

// 2. let coll be a collection
let collection: Collection;

beforeEach(async function () {
client = this.configuration.newClient();
collection = client.db('foo').collection('bar');
});

afterEach(async function () {
sinon.restore();
await client?.close();
});

test(
'works',
{
requires: {
mongodb: '>=4.4', // failCommand
topology: '!single' // transactions can't run on standalone servers
}
},
async function () {
const randomStub = sinon.stub(Math, 'random');

// 3.i Configure the random number generator used for jitter to always return 0
randomStub.returns(0);

// 3.ii Configure a fail point that forces 13 retries
await configureFailPoint(this.configuration, failCommand);

// 3.iii
const callback = async (s: ClientSession) => {
await collection.insertOne({}, { session: s });
};

// 3.iv Let no_backoff_time be the duration of the withTransaction API call
const { duration: noBackoffTime } = await measureDuration(() => {
return client.withSession(async s => {
await s.withTransaction(callback);
});
});

// 4.i Configure the random number generator used for jitter to always return 1.
randomStub.returns(1);

// 4.ii Configure a fail point that forces 13 retries like in step 3.2.
await configureFailPoint(this.configuration, failCommand);

// 4.iii Use the same callback defined in 3.3.
// 4.iv Let with_backoff_time be the duration of the withTransaction API call
const { duration: fullBackoffDuration } = await measureDuration(() => {
return client.withSession(async s => {
await s.withTransaction(callback);
});
});

// 5. Compare the two time between the two runs.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this is how we catch spec typos 😅 time -> times
let's update here and maybe include this in the next spec PR or as a one-off?

// 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.
expect(fullBackoffDuration).to.be.within(
noBackoffTime + 2200 - 1000,
noBackoffTime + 2200 + 1000
);
}
);
});