Skip to content

Commit a7dab96

Browse files
authored
feat(NODE-4503): throw original error when server attaches NoWritesPerformed label (#3441)
1 parent dbfb7d5 commit a7dab96

File tree

4 files changed

+143
-19
lines changed

4 files changed

+143
-19
lines changed

src/error.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ export const MongoErrorLabel = Object.freeze({
9090
UnknownTransactionCommitResult: 'UnknownTransactionCommitResult',
9191
ResumableChangeStreamError: 'ResumableChangeStreamError',
9292
HandshakeError: 'HandshakeError',
93-
ResetPool: 'ResetPool'
93+
ResetPool: 'ResetPool',
94+
NoWritesPerformed: 'NoWritesPerformed'
9495
} as const);
9596

9697
/** @public */

src/operations/execute_operation.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
MongoCompatibilityError,
66
MONGODB_ERROR_CODES,
77
MongoError,
8+
MongoErrorLabel,
89
MongoExpiredSessionError,
910
MongoNetworkError,
1011
MongoNotConnectedError,
@@ -272,5 +273,15 @@ async function retryOperation<
272273
);
273274
}
274275

275-
return operation.executeAsync(server, session);
276+
try {
277+
return await operation.executeAsync(server, session);
278+
} catch (retryError) {
279+
if (
280+
retryError instanceof MongoError &&
281+
retryError.hasErrorLabel(MongoErrorLabel.NoWritesPerformed)
282+
) {
283+
throw originalError;
284+
}
285+
throw retryError;
286+
}
276287
}

test/integration/retryable-writes/retryable_writes.spec.prose.test.ts

+128-16
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,20 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
22
import { expect } from 'chai';
3+
import * as sinon from 'sinon';
34

4-
import { Collection, MongoClient, MongoError, MongoServerError } from '../../../src';
5+
import {
6+
Collection,
7+
MongoClient,
8+
MongoError,
9+
MongoServerError,
10+
MongoWriteConcernError
11+
} from '../../../src';
12+
import { Server } from '../../../src/sdam/server';
513

614
describe('Retryable Writes Spec Prose', () => {
7-
let client: MongoClient, failPointName;
8-
9-
afterEach(async () => {
10-
try {
11-
if (failPointName) {
12-
await client.db('admin').command({ configureFailPoint: failPointName, mode: 'off' });
13-
}
14-
} finally {
15-
failPointName = undefined;
16-
await client?.close();
17-
}
18-
});
19-
2015
describe('1. Test that retryable writes raise an exception when using the MMAPv1 storage engine.', () => {
16+
let client: MongoClient;
17+
let failPointName: string | undefined;
2118
/**
2219
* For this test, execute a write operation, such as insertOne, which should generate an exception and the error code is 20.
2320
* Assert that the error message is the replacement error message:
@@ -46,10 +43,21 @@ describe('Retryable Writes Spec Prose', () => {
4643
expect(failPoint).to.have.property('ok', 1);
4744
});
4845

49-
for (const testTopology of ['replicaset', 'sharded']) {
46+
afterEach(async () => {
47+
try {
48+
if (failPointName) {
49+
await client.db('admin').command({ configureFailPoint: failPointName, mode: 'off' });
50+
}
51+
} finally {
52+
failPointName = undefined;
53+
await client?.close();
54+
}
55+
});
56+
57+
for (const testTopology of ['replicaset', 'sharded'] as const) {
5058
const minFailPointVersion = testTopology === 'replicaset' ? '>=4.0.0' : '>=4.1.5';
5159
it(`should error with the correct error message when topology is ${testTopology}`, {
52-
metadata: { requires: { mongodb: minFailPointVersion, topology: [testTopology as any] } },
60+
metadata: { requires: { mongodb: minFailPointVersion, topology: [testTopology] } },
5361
test: async function () {
5462
const error = await client
5563
.db('test')
@@ -74,6 +82,8 @@ describe('Retryable Writes Spec Prose', () => {
7482
// This test MUST be implemented by any driver that implements the CMAP specification.
7583
// This test requires MongoDB 4.2.9+ for blockConnection support in the failpoint.
7684

85+
let client: MongoClient;
86+
let failPointName: string | undefined;
7787
let cmapEvents: Array<{ name: string; event: Record<string, any> }>;
7888
let commandStartedEvents: Array<Record<string, any>>;
7989
let testCollection: Collection;
@@ -123,6 +133,17 @@ describe('Retryable Writes Spec Prose', () => {
123133
});
124134
});
125135

136+
afterEach(async () => {
137+
try {
138+
if (failPointName) {
139+
await client.db('admin').command({ configureFailPoint: failPointName, mode: 'off' });
140+
}
141+
} finally {
142+
failPointName = undefined;
143+
await client?.close();
144+
}
145+
});
146+
126147
it('should emit events in the expected sequence', {
127148
metadata: { requires: { mongodb: '>=4.2.9', topology: ['replicaset', 'sharded'] } },
128149
test: async function () {
@@ -188,4 +209,95 @@ describe('Retryable Writes Spec Prose', () => {
188209
}
189210
});
190211
});
212+
213+
describe('3. Test that drivers return the original error after encountering a WriteConcernError with a RetryableWriteError label', () => {
214+
let client: MongoClient;
215+
let collection: Collection<{ _id: 1 }>;
216+
217+
beforeEach(async function () {
218+
client = this.configuration.newClient({ monitorCommands: true, retryWrites: true });
219+
await client
220+
.db()
221+
.collection('retryReturnsOriginal')
222+
.drop()
223+
.catch(() => null);
224+
collection = client.db().collection('retryReturnsOriginal');
225+
});
226+
227+
afterEach(async function () {
228+
sinon.restore();
229+
await client.close();
230+
});
231+
232+
/**
233+
* **NOTE:** Node emits a command failed event for writeConcern errors, making the commandSucceeded part of this test inconsistent see (DRIVERS-2468).
234+
* Second our event emitters are called synchronously but operations are asynchronous, we don't have a way to make sure a fail point is set before a retry
235+
* is attempted, if the server allowed us to specify an ordered list of fail points this would be possible, alas we can use sinon. Sinon will set an error
236+
* to be thrown on the first and second call to Server.command(), this should enter the retry logic for the second error thrown.
237+
*
238+
* This test MUST be implemented by any driver that implements the Command Monitoring specification,
239+
* only run against replica sets as mongos does not propagate the NoWritesPerformed label to the drivers.
240+
* Additionally, this test requires drivers to set a fail point after an insertOne operation but before the subsequent retry.
241+
* Drivers that are unable to set a failCommand after the CommandSucceededEvent SHOULD use mocking or write a unit test to cover the same sequence of events.
242+
*
243+
* Create a client with retryWrites=true.
244+
*
245+
* Configure a fail point with error code 91 (ShutdownInProgress):
246+
* ```js
247+
* db.adminCommand({
248+
* configureFailPoint: 'failCommand',
249+
* mode: { times: 1 },
250+
* data: {
251+
* writeConcernError: {
252+
* code: 91,
253+
* errorLabels: ['RetryableWriteError']
254+
* },
255+
* failCommands: ['insert']
256+
* }
257+
* });
258+
* ```
259+
* Via the command monitoring CommandSucceededEvent, configure a fail point with error code 10107 (NotWritablePrimary) and a NoWritesPerformed label:
260+
*
261+
* ```js
262+
* db.adminCommand({
263+
* configureFailPoint: 'failCommand',
264+
* mode: { times: 1 },
265+
* data: {
266+
* errorCode: 10107,
267+
* errorLabels: ['RetryableWriteError', 'NoWritesPerformed'],
268+
* failCommands: ['insert']
269+
* }
270+
* });
271+
* ```
272+
* Drivers SHOULD only configure the 10107 fail point command if the the succeeded event is for the 91 error configured in step 2.
273+
*
274+
* Attempt an insertOne operation on any record for any database and collection. For the resulting error, assert that the associated error code is 91.
275+
*/
276+
it(
277+
'when a retry attempt fails with an error labeled NoWritesPerformed, drivers MUST return the original error',
278+
{ requires: { topology: 'replicaset', mongodb: '>=4.2.9' } },
279+
async () => {
280+
const serverCommandStub = sinon.stub(Server.prototype, 'command');
281+
serverCommandStub
282+
.onCall(0)
283+
.yieldsRight(
284+
new MongoWriteConcernError({ errorLabels: ['RetryableWriteError'], code: 91 }, {})
285+
);
286+
serverCommandStub
287+
.onCall(1)
288+
.yieldsRight(
289+
new MongoWriteConcernError(
290+
{ errorLabels: ['RetryableWriteError', 'NoWritesPerformed'], errorCode: 10107 },
291+
{}
292+
)
293+
);
294+
295+
const insertResult = await collection.insertOne({ _id: 1 }).catch(error => error);
296+
sinon.restore();
297+
298+
expect(insertResult).to.be.instanceOf(MongoServerError);
299+
expect(insertResult).to.have.property('code', 91);
300+
}
301+
);
302+
});
191303
});

test/tools/utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ export interface FailPoint {
294294
closeConnection?: boolean;
295295
blockConnection?: boolean;
296296
blockTimeMS?: number;
297-
writeConcernError?: { code: number; errmsg: string };
297+
writeConcernError?: { code: number; errmsg?: string; errorLabels?: string[] };
298298
threadName?: string;
299299
failInternalCommands?: boolean;
300300
errorLabels?: string[];

0 commit comments

Comments
 (0)