Skip to content
2 changes: 1 addition & 1 deletion src/Operation/WithTransaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ private function computeBackoffMs(int $transactionAttempt): int

private function getJitter(): float
{
if ($this->jitterGenerator) {
if ($this->jitterGenerator !== null) {
return ($this->jitterGenerator)();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace MongoDB\Tests\SpecTests\ClientBackpressure;

use MongoDB\Driver\Exception\ServerException;
use MongoDB\Driver\Session;
use MongoDB\Operation\WithTransaction;
use MongoDB\Tests\SpecTests\FunctionalTestCase;

use function hrtime;

/**
* Prose test 1: Retry operation uses exponential backoff
*
* @see https://github.com/mongodb/specifications/blob/master/source/client-backpressure/tests/README.md#test-1-operation-retry-uses-exponential-backoff
*/
class Prose1_OpRetryExponentialBackoffTest extends FunctionalTestCase
{
public function testOperationRetryUsesExponentialBackoff(): void
{
$this->skipIfTransactionsAreNotSupported();
$this->skipIfServerVersion('<', '4.3.1', 'Test requires configureFailPoint to support errorLabels');

$client = self::createTestClient();
$collection = $client->selectCollection($this->getDatabaseName(), $this->getCollectionName());

$callback = static function (Session $session) use ($collection): void {
$collection->insertOne(['a' => 1], ['session' => $session]);
};

$operation = new WithTransaction($callback);
$session = $client->startSession();

$this->configureFailPoint([
'configureFailPoint' => 'failCommand',
'mode' => 'alwaysOn',
'data' => [
'failCommands' => ['insert'],
'errorCode' => 2,
'errorLabels' => ['SystemOverloadedError', 'RetryableError'],
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failpoint only adds SystemOverloadedError and RetryableError labels, but WithTransaction::checkForRetryableError() only retries (and calls backoff()) for exceptions with the TransientTransactionError label. As written, execute() will rethrow immediately and jitter/backoff timing assertions won’t be exercising the intended retry path. Consider adjusting the failpoint/error labels (or the operation under test) so it triggers the retry/backoff logic you want to measure.

Suggested change
'errorLabels' => ['SystemOverloadedError', 'RetryableError'],
'errorLabels' => ['SystemOverloadedError', 'RetryableError', 'TransientTransactionError'],

Copilot uses AI. Check for mistakes.
],
]);
Comment thread
GromNaN marked this conversation as resolved.
Comment thread
GromNaN marked this conversation as resolved.

$start = hrtime(true);

try {
$operation->execute($session);
$this->fail('Expected exception was not thrown');
} catch (ServerException) {
// Expected exception due to failCommand
}

$elapsed = (hrtime(true) - $start) / 1e9;

/* The spec requires comparing two runs with jitter fixed at 0 and 1 to verify
* that backoff delay scales with the jitter value (expected difference: ~0.3s).
*
* This is not achievable from PHPLIB because the overload retry and its backoff
* are implemented inside ext-mongodb (C level). WithTransaction only retries on
* TransientTransactionError, not on SystemOverloadedError, so setFixedJitter()
* has no effect on the timing of this test.
*
* As partial verification, we assert that the operation completed within the
* maximum possible backoff window: MAX_RETRIES (2) × MAX_BACKOFF (10s) = 20s. */
self::assertLessThan(20.0, $elapsed);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion has been updated to reflect the fact that we cannot force a specific jitter value.

}
Comment thread
GromNaN marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace MongoDB\Tests\SpecTests\ClientBackpressure;

use MongoDB\Driver\Exception\RuntimeException;
use MongoDB\Driver\Monitoring\CommandFailedEvent;
use MongoDB\Driver\Monitoring\CommandStartedEvent;
use MongoDB\Driver\Monitoring\CommandSubscriber;
use MongoDB\Driver\Monitoring\CommandSucceededEvent;
use MongoDB\Tests\SpecTests\FunctionalTestCase;

/**
* Prose test 3: Overload Errors are Retried a Maximum of MAX_RETRIES times
*
* @see https://github.com/mongodb/specifications/blob/master/source/client-backpressure/tests/README.md#test-3-overload-errors-are-retried-a-maximum-of-max_retries-times
*/
class Prose3_OverloadErrorMaxRetryTest extends FunctionalTestCase
{
private const MAX_RETRIES = 2;

public function testOverloadErrorsAreRetriedMaxRetryTimes(): void
{
$this->skipIfServerVersion('<', '4.3.1', 'Test requires configureFailPoint to support errorLabels');

$client = self::createTestClient();
Comment thread
GromNaN marked this conversation as resolved.
$collection = $client->getCollection($this->getDatabaseName(), $this->getCollectionName());

$subscriber = new class implements CommandSubscriber {
public int $findCommandsStarted = 0;

public function commandStarted(CommandStartedEvent $event): void
{
if ($event->getCommandName() === 'find') {
$this->findCommandsStarted++;
}
}

public function commandSucceeded(CommandSucceededEvent $event): void
{
}

public function commandFailed(CommandFailedEvent $event): void
{
}
};

$client->addSubscriber($subscriber);

$this->configureFailPoint([
'configureFailPoint' => 'failCommand',
'mode' => 'alwaysOn',
'data' => [
'failCommands' => ['find'],
'errorCode' => 462, // IngressRequestRateLimitExceeded
'errorLabels' => ['SystemOverloadedError', 'RetryableError'],
],
]);
Comment thread
GromNaN marked this conversation as resolved.

try {
$collection->find([]);
$this->fail('Expected RuntimeException was not thrown');
} catch (RuntimeException $e) {
$this->assertTrue($e->hasErrorLabel('RetryableError'));
$this->assertTrue($e->hasErrorLabel('SystemOverloadedError'));
}

$client->removeSubscriber($subscriber);

$this->assertSame(self::MAX_RETRIES + 1, $subscriber->findCommandsStarted);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace MongoDB\Tests\SpecTests\ClientBackpressure;

use MongoDB\Driver\Exception\RuntimeException;
use MongoDB\Driver\Monitoring\CommandFailedEvent;
use MongoDB\Driver\Monitoring\CommandStartedEvent;
use MongoDB\Driver\Monitoring\CommandSubscriber;
use MongoDB\Driver\Monitoring\CommandSucceededEvent;
use MongoDB\Tests\SpecTests\FunctionalTestCase;

/**
* Prose test 4: Overload Errors are Retried a Maximum of maxAdaptiveRetries times when configured
*
* @see https://github.com/mongodb/specifications/blob/master/source/client-backpressure/tests/README.md#test-4-overload-errors-are-retried-a-maximum-of-maxadaptiveretries-times-when-configured
*/
class Prose4_OverloadErrorMaxAdaptiveRetriesTest extends FunctionalTestCase
{
private const MAX_ADAPTIVE_RETRIES = 1;

public function testOverloadErrorsAreRetriedMaxAdaptiveRetryTimes(): void
{
$this->skipIfServerVersion('<', '4.3.1', 'Test requires configureFailPoint to support errorLabels');

$client = self::createTestClient(options: ['maxAdaptiveRetries' => self::MAX_ADAPTIVE_RETRIES]);
$collection = $client->getCollection($this->getDatabaseName(), $this->getCollectionName());

$subscriber = new class implements CommandSubscriber {
public int $findCommandsStarted = 0;

public function commandStarted(CommandStartedEvent $event): void
{
if ($event->getCommandName() === 'find') {
$this->findCommandsStarted++;
}
}

public function commandSucceeded(CommandSucceededEvent $event): void
{
}

public function commandFailed(CommandFailedEvent $event): void
{
}
};

$client->addSubscriber($subscriber);

$this->configureFailPoint([
'configureFailPoint' => 'failCommand',
'mode' => 'alwaysOn',
'data' => [
'failCommands' => ['find'],
'errorCode' => 462, // IngressRequestRateLimitExceeded
'errorLabels' => ['SystemOverloadedError', 'RetryableError'],
],
]);

try {
$collection->find([]);
$this->fail('Expected RuntimeException was not thrown');
} catch (RuntimeException $e) {
$this->assertTrue($e->hasErrorLabel('RetryableError'));
$this->assertTrue($e->hasErrorLabel('SystemOverloadedError'));
}

$client->removeSubscriber($subscriber);

$this->assertSame(self::MAX_ADAPTIVE_RETRIES + 1, $subscriber->findCommandsStarted);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use MongoDB\Driver\Session;
use MongoDB\Operation\WithTransaction;
use MongoDB\Tests\SpecTests\FunctionalTestCase;
use ReflectionProperty;
use MongoDB\Tests\UnifiedSpecTests\Util;

use function microtime;

Expand All @@ -29,10 +29,10 @@ public function testBackoffIsEnforced(): void
$operation = new WithTransaction($callback);
$session = $client->startSession();

$this->setFixedJitter($operation, 0);
Util::setFixedJitter($operation, 0);
$noBackoffTime = $this->runOperationWithTiming($operation, $session);

$this->setFixedJitter($operation, 1);
Util::setFixedJitter($operation, 1);
$withBackoffTime = $this->runOperationWithTiming($operation, $session);

self::assertEqualsWithDelta($noBackoffTime + 1.8, $withBackoffTime, 0.5);
Expand All @@ -48,15 +48,6 @@ private function runOperationWithTiming(WithTransaction $operation, Session $ses
return microtime(true) - $start;
}

private function setFixedJitter(WithTransaction $operation, float $jitter): void
{
(new ReflectionProperty($operation, 'jitterGenerator'))
->setValue(
$operation,
static fn (): float => $jitter,
);
}

private function setUpCommitTransactionFailPoint(): void
{
$this->configureFailPoint([
Expand Down
11 changes: 11 additions & 0 deletions tests/UnifiedSpecTests/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
use MongoDB\Driver\Session;
use MongoDB\Driver\WriteConcern;
use MongoDB\GridFS\Bucket;
use MongoDB\Operation\WithTransaction;
use ReflectionProperty;
use stdClass;

use function array_diff_key;
Expand Down Expand Up @@ -239,4 +241,13 @@ public static function prepareCommonOptions(array $options): array

return $options;
}

public static function setFixedJitter(WithTransaction $operation, float $jitter): void
{
(new ReflectionProperty($operation, 'jitterGenerator'))
->setValue(
$operation,
static fn (): float => $jitter,
);
}
}
Loading