Skip to content

Commit 90e0bc3

Browse files
committed
feat(NODE-4503): throw original error when server attaches NoWritesPerformed label
1 parent ca51fec commit 90e0bc3

File tree

4 files changed

+135
-17
lines changed

4 files changed

+135
-17
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

+120-14
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
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 { Collection, MongoClient, MongoServerError, MongoWriteConcernError } from '../../../src';
6+
import { Server } from '../../../src/sdam/server';
57

68
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-
209
describe('1. Test that retryable writes raise an exception when using the MMAPv1 storage engine.', () => {
10+
let client: MongoClient;
11+
let failPointName: string | undefined;
2112
/**
2213
* For this test, execute a write operation, such as insertOne, which should generate an exception and the error code is 20.
2314
* Assert that the error message is the replacement error message:
@@ -46,6 +37,17 @@ describe('Retryable Writes Spec Prose', () => {
4637
expect(failPoint).to.have.property('ok', 1);
4738
});
4839

40+
afterEach(async () => {
41+
try {
42+
if (failPointName) {
43+
await client.db('admin').command({ configureFailPoint: failPointName, mode: 'off' });
44+
}
45+
} finally {
46+
failPointName = undefined;
47+
await client?.close();
48+
}
49+
});
50+
4951
for (const testTopology of ['replicaset', 'sharded']) {
5052
const minFailPointVersion = testTopology === 'replicaset' ? '>=4.0.0' : '>=4.1.5';
5153
it(`should error with the correct error message when topology is ${testTopology}`, {
@@ -74,6 +76,8 @@ describe('Retryable Writes Spec Prose', () => {
7476
// This test MUST be implemented by any driver that implements the CMAP specification.
7577
// This test requires MongoDB 4.2.9+ for blockConnection support in the failpoint.
7678

79+
let client: MongoClient;
80+
let failPointName: string | undefined;
7781
let cmapEvents: Array<{ name: string; event: Record<string, any> }>;
7882
let commandStartedEvents: Array<Record<string, any>>;
7983
let testCollection: Collection;
@@ -123,6 +127,17 @@ describe('Retryable Writes Spec Prose', () => {
123127
});
124128
});
125129

130+
afterEach(async () => {
131+
try {
132+
if (failPointName) {
133+
await client.db('admin').command({ configureFailPoint: failPointName, mode: 'off' });
134+
}
135+
} finally {
136+
failPointName = undefined;
137+
await client?.close();
138+
}
139+
});
140+
126141
it('should emit events in the expected sequence', {
127142
metadata: { requires: { mongodb: '>=4.2.9', topology: ['replicaset', 'sharded'] } },
128143
test: async function () {
@@ -188,4 +203,95 @@ describe('Retryable Writes Spec Prose', () => {
188203
}
189204
});
190205
});
206+
207+
describe('3. Test that drivers return the original error after encountering a WriteConcernError with a RetryableWriteError label', () => {
208+
let client: MongoClient;
209+
let collection: Collection<{ _id: 1 }>;
210+
211+
beforeEach(async function () {
212+
client = this.configuration.newClient({ monitorCommands: true, retryWrites: true });
213+
await client
214+
.db()
215+
.collection('retryReturnsOriginal')
216+
.drop()
217+
.catch(() => null);
218+
collection = client.db().collection('retryReturnsOriginal');
219+
});
220+
221+
afterEach(async function () {
222+
sinon.restore();
223+
await client.close();
224+
});
225+
226+
/**
227+
* **NOTE:** Node emits a command failed event for writeConcern errors, making the commandSucceeded part of this test inconsistent see (DRIVERS-2468).
228+
* 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
229+
* 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
230+
* to be thrown on the first and second call to Server.command(), this should enter the retry logic for the second error thrown.
231+
*
232+
* This test MUST be implemented by any driver that implements the Command Monitoring specification,
233+
* only run against replica sets as mongos does not propagate the NoWritesPerformed label to the drivers.
234+
* Additionally, this test requires drivers to set a fail point after an insertOne operation but before the subsequent retry.
235+
* 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.
236+
*
237+
* Create a client with retryWrites=true.
238+
*
239+
* Configure a fail point with error code 91 (ShutdownInProgress):
240+
* ```js
241+
* db.adminCommand({
242+
* configureFailPoint: 'failCommand',
243+
* mode: { times: 1 },
244+
* data: {
245+
* writeConcernError: {
246+
* code: 91,
247+
* errorLabels: ['RetryableWriteError']
248+
* },
249+
* failCommands: ['insert']
250+
* }
251+
* });
252+
* ```
253+
* Via the command monitoring CommandSucceededEvent, configure a fail point with error code 10107 (NotWritablePrimary) and a NoWritesPerformed label:
254+
*
255+
* ```js
256+
* db.adminCommand({
257+
* configureFailPoint: 'failCommand',
258+
* mode: { times: 1 },
259+
* data: {
260+
* errorCode: 10107,
261+
* errorLabels: ['RetryableWriteError', 'NoWritesPerformed'],
262+
* failCommands: ['insert']
263+
* }
264+
* });
265+
* ```
266+
* Drivers SHOULD only configure the 10107 fail point command if the the succeeded event is for the 91 error configured in step 2.
267+
*
268+
* Attempt an insertOne operation on any record for any database and collection. For the resulting error, assert that the associated error code is 91.
269+
*/
270+
it(
271+
'when a retry attempt fails with an error labeled NoWritesPerformed, drivers MUST return the original error.',
272+
{ requires: { topology: 'replicaset', mongodb: '>=4.2.9' } },
273+
async () => {
274+
const serverCommandStub = sinon.stub(Server.prototype, 'command');
275+
serverCommandStub
276+
.onCall(0)
277+
.yieldsRight(
278+
new MongoWriteConcernError({ errorLabels: ['RetryableWriteError'], code: 91 }, {})
279+
);
280+
serverCommandStub
281+
.onCall(1)
282+
.yieldsRight(
283+
new MongoWriteConcernError(
284+
{ errorLabels: ['RetryableWriteError', 'NoWritesPerformed'], errorCode: 10107 },
285+
{}
286+
)
287+
);
288+
289+
const insertResult = await collection.insertOne({ _id: 1 }).catch(error => error);
290+
sinon.restore();
291+
292+
expect(insertResult).to.be.instanceOf(MongoServerError);
293+
expect(insertResult).to.have.property('code', 91);
294+
}
295+
);
296+
});
191297
});

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)