Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
39ee632
Pass CommandInterface to retry handler (fixes #1155, #1130)
bautrukevich Mar 29, 2026
2c6522b
Enhance internalExecute to support automatic connection renewal on fi…
bautrukevich Mar 29, 2026
ee253e3
Apply PHP CS Fixer and Rector changes (CI)
bautrukevich Mar 29, 2026
a8c1cc9
Update AbstractPdoCommand.php to support retries with bindParam storage
bautrukevich Mar 31, 2026
83d7d99
Apply suggestions from code review
samdark Apr 1, 2026
76e66cc
Enhance internalExecute to support automatic connection renewal on fi…
bautrukevich Apr 2, 2026
57f83d1
Apply PHP CS Fixer and Rector changes (CI)
bautrukevich Apr 2, 2026
a4653a5
Add psalm type hint for retry handler in AbstractCommand and CommandI…
bautrukevich Apr 2, 2026
91d570b
Clarify documentation for retry handler closure in AbstractCommand an…
bautrukevich Apr 2, 2026
9f4ca1e
Merge remote-tracking branch 'origin/feature/pass-command-to-retry-ha…
bautrukevich Apr 2, 2026
0f64f53
Update src/Command/AbstractCommand.php
bautrukevich Apr 3, 2026
08fd065
Remove rebindBoundParams method and update documentation for paramete…
bautrukevich Apr 3, 2026
67c2e8d
Implement connection recovery handler and enhance error handling in A…
bautrukevich Apr 3, 2026
8f6f17a
Update src/Driver/Pdo/AbstractPdoCommand.php
bautrukevich Apr 3, 2026
79f449d
Apply PHP CS Fixer and Rector changes (CI)
bautrukevich Apr 3, 2026
ffb39dd
Remove isConnectionError method from AbstractPdoCommand
bautrukevich Apr 3, 2026
c0811d7
Refactor parameter binding logic in AbstractPdoCommand
bautrukevich Apr 3, 2026
836a751
Add tests for parameter binding behavior across executions and after …
bautrukevich Apr 3, 2026
1d38847
Add ConnectionException and ConnectionRecoveryHandler classes; enhanc…
bautrukevich Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions src/Command/CommandInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -770,26 +770,29 @@ public function showDatabases(): array;
public function setRawSql(string $sql): static;

/**
* Sets a closure (anonymous function) which called when a database exception is thrown when executing the command.
* Sets a closure (anonymous function) that is called when a database exception is thrown when executing the command.
*
* The signature of the closure should be:
*
* ```php
* use Yiisoft\Db\Exception\Exception;
* use Yiisoft\Db\Command\CommandInterface;
*
* function (Exception $e, int $attempt): bool
* function (Exception $e, int $attempt, CommandInterface $command): bool
* {
* // return true or false (whether to retry the command or throw $e)
* }
* ```
*
* The closure will receive an {@see Exception} converted from the thrown database exception and the current attempt
* to execute the command, starting from `1`.
* The closure will receive an {@see Exception} converted from the thrown database exception,
* the current attempt to execute the command (starting from `0`), and the {@see CommandInterface}
* instance to allow access to the command and its parameters for custom retry logic.
*
* If the closure returns `true`, the command will be retried. If the closure returns `false`, the {@see Exception}
* will be thrown.
* If the closure returns `true`, the command will be retried. If the closure returns `false`,
* the {@see Exception} will be thrown.
*
* @param Closure|null $handler A PHP callback to handle database exceptions.
Comment thread
Tigrov marked this conversation as resolved.
* @psalm-param Closure(Exception, int, CommandInterface): bool|null $handler
*/
public function setRetryHandler(?Closure $handler): static;

Expand Down
98 changes: 96 additions & 2 deletions src/Driver/Pdo/AbstractPdoCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ abstract class AbstractPdoCommand extends AbstractCommand implements PdoCommandI
*/
protected ?PDOStatement $pdoStatement = null;

/**
* @var array<int|string, array{value: mixed, type: int, length: int|null, driverOptions: mixed}>
* Parameters bound via {@see bindParam()} stored by reference for re-binding after statement re-preparation
* (e.g., on reconnect).
*/
protected array $pendingBoundParams = [];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
protected array $pendingBoundParams = [];
protected array $bindingParams = [];

We have AbstractCommand::$params, which in PDO is bound using PDOStatement::bindValue().
And $pendingBoundParams is bound using PDOStatement::bindParam().
I suggest to rename it to $bindingParams or $pendingParams.

Perhaps good idea to rename AbstractCommand::$params to $bindingValues or $pendingValues in the future.


/**
* @param PdoConnectionInterface $db The PDO database connection to use.
*/
Expand Down Expand Up @@ -87,6 +94,11 @@ public function bindParam(
$dataType = $this->db->getSchema()->getDataType($value);
}

// Save the binding by reference so it can be re-applied after statement re-preparation (e.g., on reconnect).
$entry = ['type' => $dataType, 'length' => $length, 'driverOptions' => $driverOptions, 'value' => null];
$entry['value'] = &$value;
Comment thread
bautrukevich marked this conversation as resolved.
Outdated
$this->pendingBoundParams[$name] = $entry;

if ($length === null) {
$this->pdoStatement?->bindParam($name, $value, $dataType);
} elseif ($driverOptions === null) {
Expand Down Expand Up @@ -154,6 +166,7 @@ public function prepare(?bool $forRead = null): void
try {
$this->pdoStatement = $pdo->prepare($sql);
$this->bindPendingParams();
$this->rebindBoundParams();
Comment thread
Tigrov marked this conversation as resolved.
Outdated
} catch (PDOException $e) {
$message = $e->getMessage() . "\nFailed to prepare SQL: $sql";
$errorInfo = $e->errorInfo ?? null;
Expand All @@ -174,6 +187,35 @@ protected function bindPendingParams(): void
}
}

/**
* Re-binds parameters registered via {@see bindParam()} to the current {@see PDOStatement}.
*
* Called after statement re-preparation (e.g., after reconnect) to restore by-reference bindings.
*/
protected function rebindBoundParams(): void
{
foreach ($this->pendingBoundParams as $name => &$entry) {
$value = &$entry['value'];

if ($entry['length'] === null) {
$this->pdoStatement?->bindParam($name, $value, $entry['type']);
} elseif ($entry['driverOptions'] === null) {
$this->pdoStatement?->bindParam($name, $value, $entry['type'], $entry['length']);
} else {
$this->pdoStatement?->bindParam($name, $value, $entry['type'], $entry['length'], $entry['driverOptions']);
}

unset($value);
}
unset($entry);
}
Comment thread
Tigrov marked this conversation as resolved.
Outdated

protected function reset(): void
{
parent::reset();
$this->pendingBoundParams = [];
}

protected function getQueryBuilder(): QueryBuilderInterface
{
return $this->db->getQueryBuilder()->withTypecasting($this->dbTypecasting);
Expand All @@ -195,6 +237,9 @@ protected function getQueryMode(int $queryMode): string
/**
* A wrapper around {@see pdoStatementExecute()} to support transactions and retry handlers.
*
* Implements automatic connection renewal on first attempt if connection error detected.
* Throws exception if transaction is active to prevent unsafe reconnection.
*
* @throws Exception
*/
protected function internalExecute(): void
Expand All @@ -207,9 +252,37 @@ protected function internalExecute(): void
$rawSql ??= $this->getRawSql();
$e = (new ConvertException($e, $rawSql))->run();

if ($this->retryHandler === null || !($this->retryHandler)($e, $attempt)) {
throw $e;
// Custom retry handler takes precedence
if ($this->retryHandler !== null) {
if (!($this->retryHandler)($e, $attempt, $this)) {
throw $e;
}
continue;
}

// Default behavior: attempt to renew connection on first failure
if ($attempt === 0 && $this->isConnectionError($e)) {
// Prevent reconnection during active transaction
if ($this->db->getTransaction() !== null) {
throw $e;
}

// Try to renew connection
try {
$this->db->close();
$this->db->open();
$this->pdoStatement = null;
} catch (Throwable) {
// If reconnection fails, throw original error
throw $e;
}

// Re-prepare the statement against the new connection, restoring all parameter bindings.
$this->prepare();
continue; // Retry the command
}
Comment thread
Tigrov marked this conversation as resolved.
Outdated

throw $e;
}
}
}
Expand Down Expand Up @@ -309,6 +382,27 @@ protected function queryInternal(int $queryMode): mixed
return $result;
}

/**
* Checks if the exception represents a connection error.
*
* Detects common connection-related error messages that indicate
* the database connection was lost or unavailable.
*
* @param Exception $e The exception to check
* @return bool True if the exception indicates a connection error
*/
private function isConnectionError(Exception $e): bool
{
$message = $e->getMessage();

return str_contains($message, 'no connection')
|| str_contains($message, 'General error: 7')
|| str_contains($message, 'gone away')
|| str_contains($message, 'Connection refused')
|| str_contains($message, 'server has gone away')
|| str_contains($message, 'Lost connection');
}
Comment thread
Tigrov marked this conversation as resolved.
Outdated

/**
* Returns the column instance from the query result by the index, or `null` if the column type cannot be determined.
*/
Expand Down
131 changes: 131 additions & 0 deletions tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\Tests\Driver\Pdo;

use PHPUnit\Framework\TestCase;
use Yiisoft\Db\Cache\SchemaCache;
use Yiisoft\Db\Exception\Exception;
use Yiisoft\Db\Command\CommandInterface;
use Yiisoft\Db\Tests\Support\Stub\ExecutingCommand;
use Yiisoft\Db\Tests\Support\Stub\StubConnection;
use Yiisoft\Db\Tests\Support\Stub\StubPdoDriver;
use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache;

use function is_callable;

class AbstractPdoCommandRetryHandlerTest extends TestCase
{
/**
* Test that custom retry handler receives CommandInterface parameter.
*/
public function testRetryHandlerReceivesCommandInterface(): void
{
$called = false;
$receivedCommand = null;

// Simulate retry handler behavior
$handler = function (Exception $e, int $attempt, CommandInterface $cmd) use (&$called, &$receivedCommand) {
$called = true;
$receivedCommand = $cmd;
return false;
};

$this->assertTrue(is_callable($handler));
$this->assertNull($receivedCommand); // Not called yet
}

/**
* Verifies that the built-in reconnect logic treats each known connection-error message
* as a retryable error (triggers one automatic reconnect → 2 execute calls total).
*
* @dataProvider connectionErrorMessageProvider
*/
public function testConnectionErrorMessages(string $errorMessage): void
{
$db = $this->createConnectionWithTable();
$command = new ExecutingCommand($db, failuresBeforeSuccess: 1, connectionErrorMessage: $errorMessage);
$command->setSql('SELECT 1');

$result = $command->queryScalar();

$this->assertSame('1', (string) $result);
$this->assertSame(2, $command->getExecuteCallCount(), "Expected reconnect for: $errorMessage");
}

public static function connectionErrorMessageProvider(): array
{
return [
['SQLSTATE[HY000]: General error: 7 no connection to the server'],
['server has gone away'],
['Connection refused'],
['Lost connection to MySQL server'],
];
}

/**
* Test that attempt number increments.
*/
public function testAttemptNumberIncrement(): void
{
$attempts = [];

for ($attempt = 0; $attempt < 3; $attempt++) {
$attempts[] = $attempt;
}

$this->assertEquals([0, 1, 2], $attempts);
$this->assertCount(3, $attempts);
}

/**
* Test transaction safety - no reconnect during transaction.
*/
public function testNoReconnectDuringTransaction(): void
{
$db = $this->createConnectionWithTable();
$command = new ExecutingCommand($db, failuresBeforeSuccess: 1);
$command->setSql('SELECT 1');

$transaction = $db->beginTransaction();

$this->expectException(Exception::class);

try {
$command->queryScalar();
} finally {
$transaction->rollBack();
}
}

/**
* Test parameters are collected.
*/
public function testParametersAreBound(): void
{
$params = [];

// Simulate parameter binding
$params[':id'] = 42;
$params[':name'] = 'test';

$this->assertArrayHasKey(':id', $params);
$this->assertArrayHasKey(':name', $params);
$this->assertEquals(42, $params[':id']);
$this->assertEquals('test', $params[':name']);
}

private function createConnectionWithTable(): StubConnection
{
$db = new StubConnection(
new StubPdoDriver('sqlite::memory:'),
new SchemaCache(new MemorySimpleCache()),
);
$db->open();
$pdo = $db->getActivePdo();
$pdo->exec('CREATE TABLE test (id INTEGER PRIMARY KEY)');

return $db;
}
}
Loading
Loading