Skip to content

Commit 2ffd5eb

Browse files
W-A-Jamesdariakp
authored andcommitted
feat(NODE-6231): Add CSOT behaviour for retryable reads and writes (#4186)
1 parent e4e6a5e commit 2ffd5eb

File tree

6 files changed

+44
-21
lines changed

6 files changed

+44
-21
lines changed

src/operations/execute_operation.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,10 @@ async function tryOperation<
227227
session.incrementTransactionNumber();
228228
}
229229

230-
// TODO(NODE-6231): implement infinite retry within CSOT timeout here
231-
const maxTries = willRetry ? 2 : 1;
230+
const maxTries = willRetry ? (timeoutContext.csotEnabled() ? Infinity : 2) : 1;
232231
let previousOperationError: MongoError | undefined;
233232
let previousServer: ServerDescription | undefined;
234233

235-
// TODO(NODE-6231): implement infinite retry within CSOT timeout here
236234
for (let tries = 0; tries < maxTries; tries++) {
237235
if (previousOperationError) {
238236
if (hasWriteAspect && previousOperationError.code === MMAPv1_RETRY_WRITES_ERROR_CODE) {
@@ -284,7 +282,6 @@ async function tryOperation<
284282
return await operation.execute(server, session, timeoutContext);
285283
} catch (operationError) {
286284
if (!(operationError instanceof MongoError)) throw operationError;
287-
288285
if (
289286
previousOperationError != null &&
290287
operationError.hasErrorLabel(MongoErrorLabel.NoWritesPerformed)
@@ -293,6 +290,10 @@ async function tryOperation<
293290
}
294291
previousServer = server.description;
295292
previousOperationError = operationError;
293+
294+
// Reset timeouts
295+
timeoutContext.serverSelectionTimeout?.clear();
296+
timeoutContext.connectionCheckoutTimeout?.clear();
296297
}
297298
}
298299

src/timeout.ts

+17-9
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export class Timeout extends Promise<never> {
3939
public ended: number | null = null;
4040
public duration: number;
4141
public timedOut = false;
42+
public cleared = false;
4243

4344
get remainingTime(): number {
4445
if (this.timedOut) return 0;
@@ -53,7 +54,6 @@ export class Timeout extends Promise<never> {
5354
/** Create a new timeout that expires in `duration` ms */
5455
private constructor(executor: Executor = () => null, duration: number, unref = true) {
5556
let reject!: Reject;
56-
5757
if (duration < 0) {
5858
throw new MongoInvalidArgumentError('Cannot create a Timeout with a negative duration');
5959
}
@@ -86,6 +86,7 @@ export class Timeout extends Promise<never> {
8686
clear(): void {
8787
clearTimeout(this.id);
8888
this.id = undefined;
89+
this.cleared = true;
8990
}
9091

9192
throwIfExpired(): void {
@@ -213,16 +214,20 @@ export class CSOTTimeoutContext extends TimeoutContext {
213214

214215
get serverSelectionTimeout(): Timeout | null {
215216
// check for undefined
216-
if (typeof this._serverSelectionTimeout !== 'object') {
217+
if (typeof this._serverSelectionTimeout !== 'object' || this._serverSelectionTimeout?.cleared) {
218+
const { remainingTimeMS, serverSelectionTimeoutMS } = this;
219+
if (remainingTimeMS <= 0)
220+
throw new MongoOperationTimeoutError(
221+
`Timed out in server selection after ${this.timeoutMS}ms`
222+
);
217223
const usingServerSelectionTimeoutMS =
218-
this.serverSelectionTimeoutMS !== 0 &&
219-
csotMin(this.timeoutMS, this.serverSelectionTimeoutMS) === this.serverSelectionTimeoutMS;
220-
224+
serverSelectionTimeoutMS !== 0 &&
225+
csotMin(remainingTimeMS, serverSelectionTimeoutMS) === serverSelectionTimeoutMS;
221226
if (usingServerSelectionTimeoutMS) {
222-
this._serverSelectionTimeout = Timeout.expires(this.serverSelectionTimeoutMS);
227+
this._serverSelectionTimeout = Timeout.expires(serverSelectionTimeoutMS);
223228
} else {
224-
if (this.timeoutMS > 0) {
225-
this._serverSelectionTimeout = Timeout.expires(this.timeoutMS);
229+
if (remainingTimeMS > 0 && Number.isFinite(remainingTimeMS)) {
230+
this._serverSelectionTimeout = Timeout.expires(remainingTimeMS);
226231
} else {
227232
this._serverSelectionTimeout = null;
228233
}
@@ -233,7 +238,10 @@ export class CSOTTimeoutContext extends TimeoutContext {
233238
}
234239

235240
get connectionCheckoutTimeout(): Timeout | null {
236-
if (typeof this._connectionCheckoutTimeout !== 'object') {
241+
if (
242+
typeof this._connectionCheckoutTimeout !== 'object' ||
243+
this._connectionCheckoutTimeout?.cleared
244+
) {
237245
if (typeof this._serverSelectionTimeout === 'object') {
238246
// null or Timeout
239247
this._connectionCheckoutTimeout = this._serverSelectionTimeout;

test/integration/client-side-operations-timeout/client_side_operations_timeout.spec.test.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner';
66
const enabled = [
77
'override-collection-timeoutMS',
88
'override-database-timeoutMS',
9-
'override-operation-timeoutMS'
9+
'override-operation-timeoutMS',
10+
'retryability-legacy-timeouts',
11+
'retryability-timeoutMS'
1012
];
1113

1214
const cursorOperations = [
@@ -18,6 +20,11 @@ const cursorOperations = [
1820
'listCollectionNames'
1921
];
2022

23+
const bulkWriteOperations = [
24+
'timeoutMS applies to whole operation, not individual attempts - bulkWrite on collection',
25+
'timeoutMS applies to whole operation, not individual attempts - insertMany on collection'
26+
];
27+
2128
describe('CSOT spec tests', function () {
2229
const specs = loadSpecTests(join('client-side-operations-timeout'));
2330
for (const spec of specs) {
@@ -30,6 +37,10 @@ describe('CSOT spec tests', function () {
3037
// Cursor operation
3138
if (test.operations.find(operation => cursorOperations.includes(operation.name)))
3239
test.skipReason = 'TODO(NODE-5684): Not working yet';
40+
41+
if (bulkWriteOperations.includes(test.description))
42+
test.skipReason =
43+
'TODO(NODE-6274): update test runner to check errorResponse field of MongoBulkWriteError in isTimeoutError assertion';
3344
}
3445
}
3546
runUnifiedSuite(specs);

test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { expect } from 'chai';
88
import * as sinon from 'sinon';
99

10-
import { ConnectionPool, type MongoClient, Timeout, Topology } from '../../mongodb';
10+
import { ConnectionPool, type MongoClient, Timeout, TimeoutContext, Topology } from '../../mongodb';
1111

1212
// TODO(NODE-5824): Implement CSOT prose tests
1313
describe('CSOT spec unit tests', function () {
@@ -22,10 +22,16 @@ describe('CSOT spec unit tests', function () {
2222
it('Operations should ignore waitQueueTimeoutMS if timeoutMS is also set.', async function () {
2323
client = this.configuration.newClient({ waitQueueTimeoutMS: 999999, timeoutMS: 10000 });
2424
sinon.spy(Timeout, 'expires');
25+
const timeoutContextSpy = sinon.spy(TimeoutContext, 'create');
2526

2627
await client.db('db').collection('collection').insertOne({ x: 1 });
2728

28-
expect(Timeout.expires).to.have.been.calledWith(10000);
29+
const createCalls = timeoutContextSpy.getCalls().filter(
30+
// @ts-expect-error accessing concrete field
31+
call => call.args[0].timeoutMS === 10000
32+
);
33+
34+
expect(createCalls).to.have.length.greaterThanOrEqual(1);
2935
expect(Timeout.expires).to.not.have.been.calledWith(999999);
3036
});
3137

test/integration/client-side-operations-timeout/node_csot.test.ts

-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/* Anything javascript specific relating to timeouts */
22
import { expect } from 'chai';
3-
import * as sinon from 'sinon';
43

54
import {
65
type ClientSession,
@@ -13,10 +12,6 @@ import {
1312
} from '../../mongodb';
1413

1514
describe('CSOT driver tests', () => {
16-
afterEach(() => {
17-
sinon.restore();
18-
});
19-
2015
describe('timeoutMS inheritance', () => {
2116
let client: MongoClient;
2217
let db: Db;

test/tools/unified-spec-runner/match.ts

+2
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,8 @@ export function expectErrorCheck(
790790
expect(error).to.be.instanceof(MongoOperationTimeoutError);
791791
}
792792

793+
// TODO(NODE-6274): Check for MongoBulkWriteErrors that have a MongoOperationTimeoutError in their
794+
// errorResponse field
793795
if (expected.isTimeoutError === false) {
794796
expect(error).to.not.be.instanceof(MongoOperationTimeoutError);
795797
} else if (expected.isTimeoutError === true) {

0 commit comments

Comments
 (0)