Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 47 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,54 @@ 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.
// If CSOT is not enabled, do we still have time remaining or have we timed out?
const hasNotTimedOut =
this.timeoutContext?.csotEnabled() || now() - startTime < MAX_TIMEOUT;

if (hasNotTimedOut) {
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 jitter = Math.random();
const backoffMS =
jitter * Math.min(BACKOFF_INITIAL_MS * 1.5 ** 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,72 @@
import { expect } from 'chai';
import { test } from 'mocha';
import * as sinon from 'sinon';

import { 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
}
};

describe('Retry Backoff is Enforced', function () {
let client: MongoClient;

beforeEach(async function () {
client = this.configuration.newClient();
});

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');

randomStub.returns(0);

await configureFailPoint(this.configuration, failCommand);

const { duration: noBackoffTime } = await measureDuration(() => {
return client.withSession(async s => {
await s.withTransaction(async s => {
await client.db('foo').collection('bar').insertOne({ name: 'bailey' }, { session: s });
});
});
});

randomStub.returns(1);

await configureFailPoint(this.configuration, failCommand);

const { duration: fullBackoffDuration } = await measureDuration(() => {
return client.withSession(async s => {
await s.withTransaction(async s => {
await client.db('foo').collection('bar').insertOne({ name: 'bailey' }, { session: s });
});
});
});

expect(fullBackoffDuration).to.be.within(
noBackoffTime + 2200 - 1000,
noBackoffTime + 2200 + 1000
);
}
);
});