Skip to content

Commit dc8c06a

Browse files
committed
test(NODE-7534): use real DB failPoints for retries
1 parent 8f07da6 commit dc8c06a

2 files changed

Lines changed: 215 additions & 191 deletions

File tree

test/integration/retryable-reads/retryable_reads.spec.prose.test.ts

Lines changed: 109 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
22
import { expect } from 'chai';
33
import * as sinon from 'sinon';
4+
import * as timersPromises from 'timers/promises';
45

56
import {
67
type Collection,
78
type CommandFailedEvent,
89
type CommandSucceededEvent,
910
type MongoClient,
1011
MongoErrorLabel,
11-
MongoServerError,
12-
Server
12+
MongoServerError
1313
} from '../../mongodb';
14-
import { measureDuration } from '../../tools/utils';
1514
import { filterForCommands } from '../shared';
1615

1716
describe('Retryable Reads Spec Prose', () => {
@@ -361,51 +360,66 @@ describe('Retryable Reads Spec Prose', () => {
361360
const TEST_METADATA: MongoDBMetadataUI = {
362361
requires: { mongodb: '>=4.4' }
363362
};
364-
365-
let client: MongoClient;
363+
const APP_NAME = 'retryable-reads-prose-4';
366364

367365
beforeEach(async function () {
368366
// 1. Create a client.
369367
client = this.configuration.newClient({
370368
monitorCommands: true,
371-
retryReads: true
369+
retryReads: true,
370+
appName: APP_NAME
372371
});
373372
await client.connect();
374373
});
375374

376-
afterEach(async function () {
377-
sinon.restore();
378-
await client?.close();
375+
afterEach(async () => {
376+
await client
377+
?.db('admin')
378+
.command({ configureFailPoint: 'failCommand', mode: 'off' })
379+
.catch(() => null);
379380
});
380381

381382
it(
382383
'should retry MAX_RETRIES times for all retryable errors after encountering an overload error',
383384
TEST_METADATA,
384385
async () => {
385386
// 2. Configure a fail point with error code `91` (ShutdownInProgress) with the `RetryableError` and
386-
// `SystemOverloadedError` error labels:
387-
388-
// 3. Via the command monitoring CommandFailedEvent, configure a fail point with error code `91` (ShutdownInProgress) and
389-
// the `RetryableError` label:
390-
391-
// We use mocking to simulate the failpoint sequence:
392-
// - First call: error WITH SystemOverloadedError
393-
// - Subsequent calls: error WITHOUT SystemOverloadedError (but still retryable)
394-
const serverCommandStub = sinon
395-
.stub(Server.prototype, 'command')
396-
.callsFake(async function () {
397-
const errorLabels =
398-
serverCommandStub.callCount === 1
399-
? [MongoErrorLabel.RetryableError, MongoErrorLabel.SystemOverloadedError]
400-
: [MongoErrorLabel.RetryableError];
401-
402-
throw new MongoServerError({
403-
message: 'Server Error',
404-
errorLabels,
405-
code: 91,
406-
ok: 0
407-
});
387+
// `SystemOverloadedError` error labels.
388+
await client.db('admin').command({
389+
configureFailPoint: 'failCommand',
390+
mode: { times: 1 },
391+
data: {
392+
failCommands: ['find'],
393+
errorLabels: ['RetryableError', 'SystemOverloadedError'],
394+
errorCode: 91,
395+
appName: APP_NAME
396+
}
397+
});
398+
399+
// 3. Via the command monitoring CommandFailedEvent, configure a fail point with error code `91`
400+
// (ShutdownInProgress) and the `RetryableError` label. Configure the second fail point command
401+
// only if the failed event is for the first error configured in step 2.
402+
let secondFailpointConfigured = false;
403+
client.on('commandFailed', async (event: CommandFailedEvent) => {
404+
if (secondFailpointConfigured) return;
405+
if (event.commandName !== 'find') return;
406+
secondFailpointConfigured = true;
407+
await client.db('admin').command({
408+
configureFailPoint: 'failCommand',
409+
mode: 'alwaysOn',
410+
data: {
411+
failCommands: ['find'],
412+
errorLabels: ['RetryableError'],
413+
errorCode: 91,
414+
appName: APP_NAME
415+
}
408416
});
417+
});
418+
419+
const findStartedEvents: Array<Record<string, any>> = [];
420+
client.on('commandStarted', ev => {
421+
if (ev.commandName === 'find') findStartedEvents.push(ev);
422+
});
409423

410424
// 4. Attempt a `findOne` operation on any record for any database and collection. Expect the `findOne` to fail with a
411425
// server error. Assert that `MAX_RETRIES + 1` attempts were made.
@@ -419,7 +433,9 @@ describe('Retryable Reads Spec Prose', () => {
419433
expect(error.code).to.equal(91);
420434
expect(error.hasErrorLabel(MongoErrorLabel.RetryableError)).to.be.true;
421435
// MAX_RETRIES + 1 (default maxAdaptiveRetries is 2).
422-
expect(serverCommandStub.callCount).to.equal(3);
436+
expect(findStartedEvents).to.have.lengthOf(3);
437+
438+
// 5. Disable the fail point — handled by the surrounding afterEach.
423439
}
424440
);
425441
});
@@ -431,82 +447,92 @@ describe('Retryable Reads Spec Prose', () => {
431447
const TEST_METADATA: MongoDBMetadataUI = {
432448
requires: { mongodb: '>=4.4' }
433449
};
434-
435-
let client: MongoClient;
450+
const APP_NAME = 'retryable-reads-prose-5';
436451

437452
beforeEach(async function () {
438453
// 1. Create a client.
439454
client = this.configuration.newClient({
440455
monitorCommands: true,
441-
retryReads: true
456+
retryReads: true,
457+
appName: APP_NAME
442458
});
443459
await client.connect();
444460
});
445461

446-
afterEach(async function () {
462+
afterEach(async () => {
447463
sinon.restore();
448-
await client?.close();
464+
await client
465+
?.db('admin')
466+
.command({ configureFailPoint: 'failCommand', mode: 'off' })
467+
.catch(() => null);
449468
});
450469

451470
it(
452471
'should apply backoff only once for the initial overload error and not for subsequent non-overload retryable errors',
453472
TEST_METADATA,
454473
async function () {
455-
// Configure the random number generator used for jitter to always return a number as close as possible to `1`.
456-
const randomStub = sinon.stub(Math, 'random');
457-
randomStub.returns(0.99);
474+
// Spy on `timers/promises.setTimeout` — the only sleep on the retry path
475+
// (src/operations/execute_operation.ts:337) — to count how many times backoff was applied.
476+
// We use a spy (not a stub) so the real sleep still happens, giving the commandFailed
477+
// listener below time to configure the second failpoint before the driver dispatches its
478+
// next retry.
479+
const setTimeoutSpy = sinon.spy(timersPromises, 'setTimeout');
458480

459481
// 2. Configure a fail point with error code `91` (ShutdownInProgress) with the `RetryableError` and
460-
// `SystemOverloadedError` error labels:
461-
462-
// 3. Via the command monitoring CommandFailedEvent, configure a fail point with error code `91` (ShutdownInProgress) and
463-
// the `RetryableError` label:
464-
465-
// We use mocking to simulate the failpoint sequence:
466-
// - First call: error WITH SystemOverloadedError
467-
// - Subsequent calls: error WITHOUT SystemOverloadedError (but still retryable)
468-
const serverCommandStub = sinon
469-
.stub(Server.prototype, 'command')
470-
.callsFake(async function () {
471-
const errorLabels =
472-
serverCommandStub.callCount === 1
473-
? [MongoErrorLabel.RetryableError, MongoErrorLabel.SystemOverloadedError]
474-
: [MongoErrorLabel.RetryableError];
475-
476-
throw new MongoServerError({
477-
message: 'Server Error',
478-
errorLabels,
479-
code: 91,
480-
ok: 0
481-
});
482+
// `SystemOverloadedError` error labels.
483+
await client.db('admin').command({
484+
configureFailPoint: 'failCommand',
485+
mode: { times: 1 },
486+
data: {
487+
failCommands: ['find'],
488+
errorLabels: ['RetryableError', 'SystemOverloadedError'],
489+
errorCode: 91,
490+
appName: APP_NAME
491+
}
492+
});
493+
494+
// 3. Via the command monitoring CommandFailedEvent, configure a fail point with error code `91`
495+
// (ShutdownInProgress) and the `RetryableError` label. Configure the second fail point command
496+
// only if the failed event is for the first error configured in step 2.
497+
let secondFailpointConfigured = false;
498+
client.on('commandFailed', async (event: CommandFailedEvent) => {
499+
if (secondFailpointConfigured) return;
500+
if (event.commandName !== 'find') return;
501+
secondFailpointConfigured = true;
502+
await client.db('admin').command({
503+
configureFailPoint: 'failCommand',
504+
mode: 'alwaysOn',
505+
data: {
506+
failCommands: ['find'],
507+
errorLabels: ['RetryableError'],
508+
errorCode: 91,
509+
appName: APP_NAME
510+
}
482511
});
512+
});
513+
514+
const findStartedEvents: Array<Record<string, any>> = [];
515+
client.on('commandStarted', ev => {
516+
if (ev.commandName === 'find') findStartedEvents.push(ev);
517+
});
483518

484519
// 4. Attempt a `findOne` operation on any record for any database and collection. Expect the `findOne` to fail with a
485520
// server error. Assert that backoff was applied only once for the initial overload error and not for the subsequent
486521
// non-overload retryable errors.
487-
const { duration } = await measureDuration(async () => {
488-
const error = await client
489-
.db('test')
490-
.collection('test')
491-
.findOne({})
492-
.catch(e => e);
493-
expect(error).to.be.instanceOf(MongoServerError);
494-
expect(error.code).to.equal(91);
495-
});
496-
497-
// Ensure the full retry sequence executed (i.e. the test really exercised the
498-
// post-overload non-overload retries, not just bailed after the first attempt).
499-
// MAX_RETRIES + 1 (default maxAdaptiveRetries is 2).
500-
expect(serverCommandStub.callCount).to.equal(3);
522+
const error = await client
523+
.db('test')
524+
.collection('test')
525+
.findOne({})
526+
.catch(e => e);
501527

502-
// The expected backoff for the first (overload) error is: Math.random() * Math.min(10000, 100 * 2^0)
503-
// With Math.random() = 0.99, this gives ~99ms. `measureDuration` uses `Math.floor(performance.now())`
504-
// on both ends and setTimeout can fire a couple ms early, so we allow slack on the lower bound.
505-
// If the driver incorrectly applied backoff to all retries, total would be 0.99*(100+200) = ~297ms.
506-
const expectedMinBackoff = 90; // First backoff
507-
const expectedMaxBackoff = expectedMinBackoff + 1000; // Allow 1 second margin for test overhead
528+
expect(error).to.be.instanceOf(MongoServerError);
529+
expect(error.code).to.equal(91);
530+
// MAX_RETRIES + 1 (default maxAdaptiveRetries is 2) — the full retry sequence ran.
531+
expect(findStartedEvents).to.have.lengthOf(3);
532+
// Backoff was applied exactly once — for the initial overload error only.
533+
expect(setTimeoutSpy.callCount).to.equal(1);
508534

509-
expect(duration).to.be.within(expectedMinBackoff, expectedMaxBackoff);
535+
// 5. Disable the fail point — handled by the surrounding afterEach.
510536
}
511537
);
512538
});

0 commit comments

Comments
 (0)