diff --git a/src/sessions.ts b/src/sessions.ts index 0788ff15386..f03e6aa479a 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -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'; @@ -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; } @@ -776,7 +778,7 @@ export class ClientSession throw fnError; } - while (!committed) { + for (let retry = 0; !committed; ++retry) { try { /* * We will rely on ClientSession.commitTransaction() to @@ -786,26 +788,53 @@ 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; - } + const hasNotTimedOut = + this.timeoutContext?.csotEnabled() || now() - startTime < MAX_TIMEOUT; - if ( - commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && - (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT) - ) { - break; + /** + * will the provided backoffMS exceed the withTransaction's deadline? + */ + const willExceedTransactionDeadline = (backoffMS: number) => { + return ( + (this.timeoutContext?.csotEnabled() && + backoffMS > this.timeoutContext.remainingTimeMS) || + now() + backoffMS > startTime + MAX_TIMEOUT + ); + }; + + // 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? + 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' } } + */ + + const BACKOFF_INITIAL_MS = 1; + const BACKOFF_MAX_MS = 500; + const jitter = Math.random(); + const backoffMS = + jitter * Math.min(BACKOFF_INITIAL_MS * 1.25 ** retry, BACKOFF_MAX_MS); + + if (willExceedTransactionDeadline(backoffMS)) { + break; + } + + await setTimeout(backoffMS); + + continue; + } + + if (commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) { + break; + } } throw commitError; diff --git a/test/integration/transactions-convenient-api/transactions-convenient-api.prose.test.ts b/test/integration/transactions-convenient-api/transactions-convenient-api.prose.test.ts new file mode 100644 index 00000000000..16a03f8b368 --- /dev/null +++ b/test/integration/transactions-convenient-api/transactions-convenient-api.prose.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import { test } from 'mocha'; + +import { type CommandFailedEvent, type MongoClient } from '../../mongodb'; +import { configureFailPoint } from '../../tools/utils'; +import { filterForCommands } from '../shared'; + +describe('Retry Backoff is Enforced', function () { + // Drivers should test that retries within `withTransaction` do not occur immediately. Optionally, set BACKOFF_INITIAL to a + // higher value to decrease flakiness of this test. Configure a fail point that forces 30 retries. Check that the total + // time for all retries exceeded 1.25 seconds. + + let client: MongoClient; + let failures: Array; + + beforeEach(async function () { + client = this.configuration.newClient({}, { monitorCommands: true }); + + failures = []; + client.on('commandFailed', filterForCommands('commitTransaction', failures)); + + await client.connect(); + + await configureFailPoint(this.configuration, { + configureFailPoint: 'failCommand', + mode: { + times: 30 + }, + data: { + failCommands: ['commitTransaction'], + errorCode: 24, + errorLabels: ['UnknownTransactionCommitResult'] + } + }); + }); + + afterEach(async function () { + await client?.close(); + }); + + for (let i = 0; i < 250; ++i) { + test.only('works' + i, async function () { + const start = performance.now(); + + await client.withSession(async s => { + await s.withTransaction(async s => { + await client.db('foo').collection('bar').insertOne({ name: 'bailey' }, { session: s }); + }); + }); + + const end = performance.now(); + + expect(failures).to.have.lengthOf(30); + + expect(end - start).to.be.greaterThan(1250); + }); + } +}); diff --git a/test/tools/unified-spec-runner/entities.ts b/test/tools/unified-spec-runner/entities.ts index 4e238567fd4..f332e2de332 100644 --- a/test/tools/unified-spec-runner/entities.ts +++ b/test/tools/unified-spec-runner/entities.ts @@ -629,10 +629,10 @@ export class EntitiesMap extends Map { if (entity.client.awaitMinPoolSizeMS) { if (client.topology?.s?.servers) { const timeout = Timeout.expires(entity.client.awaitMinPoolSizeMS); - const servers = client.topology.s.servers.values(); - const poolSizeChecks = Array.from(servers).map(server => - checkMinPoolSize(server.pool) + const servers = Array.from(client.topology.s.servers.values()).filter( + ({ description: { isDataBearing } }) => isDataBearing ); + const poolSizeChecks = servers.map(server => checkMinPoolSize(server.pool)); try { await Promise.race([Promise.allSettled(poolSizeChecks), timeout]); } catch (error) {