Skip to content

Commit e5b80ec

Browse files
authored
refactor(NODE-6230): executeOperation to use iterative retry mechanism (#4157)
1 parent 4b806b5 commit e5b80ec

File tree

3 files changed

+146
-140
lines changed

3 files changed

+146
-140
lines changed

src/operations/command.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export abstract class CommandOperation<T> extends AbstractOperation<T> {
106106
if (this.hasAspect(Aspect.EXPLAINABLE)) {
107107
return this.explain == null;
108108
}
109-
return true;
109+
return super.canRetryWrite;
110110
}
111111

112112
public async executeCommand<T extends MongoDBResponseConstructor>(

src/operations/execute_operation.ts

+143-137
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
import type { Topology } from '../sdam/topology';
2626
import type { ClientSession } from '../sessions';
2727
import { TimeoutContext } from '../timeout';
28-
import { squashError, supportsRetryableWrites } from '../utils';
28+
import { supportsRetryableWrites } from '../utils';
2929
import { AbstractOperation, Aspect } from './operation';
3030

3131
const MMAPv1_RETRY_WRITES_ERROR_CODE = MONGODB_ERROR_CODES.IllegalOperation;
@@ -46,10 +46,9 @@ type ResultTypeFromOperation<TOperation> = TOperation extends AbstractOperation<
4646
* not provided.
4747
*
4848
* The expectation is that this function:
49-
* - Connects the MongoClient if it has not already been connected
49+
* - Connects the MongoClient if it has not already been connected, see {@link autoConnect}
5050
* - Creates a session if none is provided and cleans up the session it creates
51-
* - Selects a server based on readPreference or various factors
52-
* - Retries an operation if it fails for certain errors, see {@link retryOperation}
51+
* - Tries an operation and retries under certain conditions, see {@link tryOperation}
5352
*
5453
* @typeParam T - The operation's type
5554
* @typeParam TResult - The type of the operation's result, calculated from T
@@ -66,23 +65,7 @@ export async function executeOperation<
6665
throw new MongoRuntimeError('This method requires a valid operation instance');
6766
}
6867

69-
if (client.topology == null) {
70-
// Auto connect on operation
71-
if (client.s.hasBeenClosed) {
72-
throw new MongoNotConnectedError('Client must be connected before running operations');
73-
}
74-
client.s.options[Symbol.for('@@mdb.skipPingOnConnect')] = true;
75-
try {
76-
await client.connect();
77-
} finally {
78-
delete client.s.options[Symbol.for('@@mdb.skipPingOnConnect')];
79-
}
80-
}
81-
82-
const { topology } = client;
83-
if (topology == null) {
84-
throw new MongoRuntimeError('client.connect did not create a topology but also did not throw');
85-
}
68+
const topology = await autoConnect(client);
8669

8770
// The driver sessions spec mandates that we implicitly create sessions for operations
8871
// that are not explicitly provided with a session.
@@ -105,17 +88,10 @@ export async function executeOperation<
10588
);
10689
}
10790

108-
timeoutContext ??= TimeoutContext.create({
109-
serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS,
110-
waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS,
111-
timeoutMS: operation.options.timeoutMS
112-
});
113-
11491
const readPreference = operation.readPreference ?? ReadPreference.primary;
11592
const inTransaction = !!session?.inTransaction();
11693

11794
const hasReadAspect = operation.hasAspect(Aspect.READ_OPERATION);
118-
const hasWriteAspect = operation.hasAspect(Aspect.WRITE_OPERATION);
11995

12096
if (
12197
inTransaction &&
@@ -131,6 +107,84 @@ export async function executeOperation<
131107
session.unpin();
132108
}
133109

110+
timeoutContext ??= TimeoutContext.create({
111+
serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS,
112+
waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS,
113+
timeoutMS: operation.options.timeoutMS
114+
});
115+
116+
try {
117+
return await tryOperation(operation, {
118+
topology,
119+
timeoutContext,
120+
session,
121+
readPreference
122+
});
123+
} finally {
124+
if (session?.owner != null && session.owner === owner) {
125+
await session.endSession();
126+
}
127+
}
128+
}
129+
130+
/**
131+
* Connects a client if it has not yet been connected
132+
* @internal
133+
*/
134+
async function autoConnect(client: MongoClient): Promise<Topology> {
135+
if (client.topology == null) {
136+
if (client.s.hasBeenClosed) {
137+
throw new MongoNotConnectedError('Client must be connected before running operations');
138+
}
139+
client.s.options[Symbol.for('@@mdb.skipPingOnConnect')] = true;
140+
try {
141+
await client.connect();
142+
if (client.topology == null) {
143+
throw new MongoRuntimeError(
144+
'client.connect did not create a topology but also did not throw'
145+
);
146+
}
147+
return client.topology;
148+
} finally {
149+
delete client.s.options[Symbol.for('@@mdb.skipPingOnConnect')];
150+
}
151+
}
152+
return client.topology;
153+
}
154+
155+
/** @internal */
156+
type RetryOptions = {
157+
session: ClientSession | undefined;
158+
readPreference: ReadPreference;
159+
topology: Topology;
160+
timeoutContext: TimeoutContext;
161+
};
162+
163+
/**
164+
* Executes an operation and retries as appropriate
165+
* @internal
166+
*
167+
* @remarks
168+
* Implements behaviour described in [Retryable Reads](https://github.com/mongodb/specifications/blob/master/source/retryable-reads/retryable-reads.md) and [Retryable
169+
* Writes](https://github.com/mongodb/specifications/blob/master/source/retryable-writes/retryable-writes.md) specification
170+
*
171+
* This function:
172+
* - performs initial server selection
173+
* - attempts to execute an operation
174+
* - retries the operation if it meets the criteria for a retryable read or a retryable write
175+
*
176+
* @typeParam T - The operation's type
177+
* @typeParam TResult - The type of the operation's result, calculated from T
178+
*
179+
* @param operation - The operation to execute
180+
* */
181+
async function tryOperation<
182+
T extends AbstractOperation<TResult>,
183+
TResult = ResultTypeFromOperation<T>
184+
>(
185+
operation: T,
186+
{ topology, timeoutContext, session, readPreference }: RetryOptions
187+
): Promise<TResult> {
134188
let selector: ReadPreference | ServerSelector;
135189

136190
if (operation.hasAspect(Aspect.MUST_SELECT_SAME_SERVER)) {
@@ -146,31 +200,15 @@ export async function executeOperation<
146200
selector = readPreference;
147201
}
148202

149-
const server = await topology.selectServer(selector, {
203+
let server = await topology.selectServer(selector, {
150204
session,
151205
operationName: operation.commandName,
152206
timeoutContext
153207
});
154208

155-
if (session == null) {
156-
// No session also means it is not retryable, early exit
157-
return await operation.execute(server, undefined, timeoutContext);
158-
}
159-
160-
if (!operation.hasAspect(Aspect.RETRYABLE)) {
161-
// non-retryable operation, early exit
162-
try {
163-
return await operation.execute(server, session, timeoutContext);
164-
} finally {
165-
if (session?.owner != null && session.owner === owner) {
166-
try {
167-
await session.endSession();
168-
} catch (error) {
169-
squashError(error);
170-
}
171-
}
172-
}
173-
}
209+
const hasReadAspect = operation.hasAspect(Aspect.READ_OPERATION);
210+
const hasWriteAspect = operation.hasAspect(Aspect.WRITE_OPERATION);
211+
const inTransaction = session?.inTransaction() ?? false;
174212

175213
const willRetryRead = topology.s.options.retryReads && !inTransaction && operation.canRetryRead;
176214

@@ -180,108 +218,76 @@ export async function executeOperation<
180218
supportsRetryableWrites(server) &&
181219
operation.canRetryWrite;
182220

183-
const willRetry = (hasReadAspect && willRetryRead) || (hasWriteAspect && willRetryWrite);
221+
const willRetry =
222+
operation.hasAspect(Aspect.RETRYABLE) &&
223+
session != null &&
224+
((hasReadAspect && willRetryRead) || (hasWriteAspect && willRetryWrite));
184225

185-
if (hasWriteAspect && willRetryWrite) {
226+
if (hasWriteAspect && willRetryWrite && session != null) {
186227
operation.options.willRetryWrite = true;
187228
session.incrementTransactionNumber();
188229
}
189230

190-
try {
191-
return await operation.execute(server, session, timeoutContext);
192-
} catch (operationError) {
193-
if (willRetry && operationError instanceof MongoError) {
194-
return await retryOperation(operation, operationError, {
195-
session,
196-
topology,
197-
selector,
198-
previousServer: server.description,
199-
timeoutContext
200-
});
201-
}
202-
throw operationError;
203-
} finally {
204-
if (session?.owner != null && session.owner === owner) {
205-
try {
206-
await session.endSession();
207-
} catch (error) {
208-
squashError(error);
209-
}
210-
}
211-
}
212-
}
231+
// TODO(NODE-6231): implement infinite retry within CSOT timeout here
232+
const maxTries = willRetry ? 2 : 1;
233+
let previousOperationError: MongoError | undefined;
234+
let previousServer: ServerDescription | undefined;
213235

214-
/** @internal */
215-
type RetryOptions = {
216-
session: ClientSession;
217-
topology: Topology;
218-
selector: ReadPreference | ServerSelector;
219-
previousServer: ServerDescription;
220-
timeoutContext: TimeoutContext;
221-
};
236+
// TODO(NODE-6231): implement infinite retry within CSOT timeout here
237+
for (let tries = 0; tries < maxTries; tries++) {
238+
if (previousOperationError) {
239+
if (hasWriteAspect && previousOperationError.code === MMAPv1_RETRY_WRITES_ERROR_CODE) {
240+
throw new MongoServerError({
241+
message: MMAPv1_RETRY_WRITES_ERROR_MESSAGE,
242+
errmsg: MMAPv1_RETRY_WRITES_ERROR_MESSAGE,
243+
originalError: previousOperationError
244+
});
245+
}
222246

223-
async function retryOperation<
224-
T extends AbstractOperation<TResult>,
225-
TResult = ResultTypeFromOperation<T>
226-
>(
227-
operation: T,
228-
originalError: MongoError,
229-
{ session, topology, selector, previousServer, timeoutContext }: RetryOptions
230-
): Promise<TResult> {
231-
const isWriteOperation = operation.hasAspect(Aspect.WRITE_OPERATION);
232-
const isReadOperation = operation.hasAspect(Aspect.READ_OPERATION);
233-
234-
if (isWriteOperation && originalError.code === MMAPv1_RETRY_WRITES_ERROR_CODE) {
235-
throw new MongoServerError({
236-
message: MMAPv1_RETRY_WRITES_ERROR_MESSAGE,
237-
errmsg: MMAPv1_RETRY_WRITES_ERROR_MESSAGE,
238-
originalError
239-
});
240-
}
247+
if (hasWriteAspect && !isRetryableWriteError(previousOperationError))
248+
throw previousOperationError;
241249

242-
if (isWriteOperation && !isRetryableWriteError(originalError)) {
243-
throw originalError;
244-
}
250+
if (hasReadAspect && !isRetryableReadError(previousOperationError))
251+
throw previousOperationError;
245252

246-
if (isReadOperation && !isRetryableReadError(originalError)) {
247-
throw originalError;
248-
}
253+
if (
254+
previousOperationError instanceof MongoNetworkError &&
255+
operation.hasAspect(Aspect.CURSOR_CREATING) &&
256+
session != null &&
257+
session.isPinned &&
258+
!session.inTransaction()
259+
) {
260+
session.unpin({ force: true, forceClear: true });
261+
}
249262

250-
if (
251-
originalError instanceof MongoNetworkError &&
252-
session.isPinned &&
253-
!session.inTransaction() &&
254-
operation.hasAspect(Aspect.CURSOR_CREATING)
255-
) {
256-
// If we have a cursor and the initial command fails with a network error,
257-
// we can retry it on another connection. So we need to check it back in, clear the
258-
// pool for the service id, and retry again.
259-
session.unpin({ force: true, forceClear: true });
260-
}
263+
server = await topology.selectServer(selector, {
264+
session,
265+
operationName: operation.commandName,
266+
previousServer
267+
});
261268

262-
// select a new server, and attempt to retry the operation
263-
const server = await topology.selectServer(selector, {
264-
session,
265-
operationName: operation.commandName,
266-
previousServer,
267-
timeoutContext
268-
});
269+
if (hasWriteAspect && !supportsRetryableWrites(server)) {
270+
throw new MongoUnexpectedServerResponseError(
271+
'Selected server does not support retryable writes'
272+
);
273+
}
274+
}
269275

270-
if (isWriteOperation && !supportsRetryableWrites(server)) {
271-
throw new MongoUnexpectedServerResponseError(
272-
'Selected server does not support retryable writes'
273-
);
274-
}
276+
try {
277+
return await operation.execute(server, session, timeoutContext);
278+
} catch (operationError) {
279+
if (!(operationError instanceof MongoError)) throw operationError;
275280

276-
try {
277-
return await operation.execute(server, session, timeoutContext);
278-
} catch (retryError) {
279-
if (
280-
retryError instanceof MongoError &&
281-
retryError.hasErrorLabel(MongoErrorLabel.NoWritesPerformed)
282-
) {
283-
throw originalError;
281+
if (
282+
previousOperationError != null &&
283+
operationError.hasErrorLabel(MongoErrorLabel.NoWritesPerformed)
284+
) {
285+
throw previousOperationError;
286+
}
287+
previousServer = server.description;
288+
previousOperationError = operationError;
284289
}
285-
throw retryError;
286290
}
291+
292+
throw previousOperationError;
287293
}

src/operations/operation.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,11 @@ export abstract class AbstractOperation<TResult = any> {
112112
}
113113

114114
get canRetryRead(): boolean {
115-
return true;
115+
return this.hasAspect(Aspect.RETRYABLE) && this.hasAspect(Aspect.READ_OPERATION);
116116
}
117117

118118
get canRetryWrite(): boolean {
119-
return true;
119+
return this.hasAspect(Aspect.RETRYABLE) && this.hasAspect(Aspect.WRITE_OPERATION);
120120
}
121121
}
122122

0 commit comments

Comments
 (0)