Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## 2.0.2 under development

- no changes in this release.
- New #1170: Add `ConnectionException` class for connection-related errors; `ConvertException` now returns it instead of plain `Exception` for lost/refused connections (@your-username)
- New #1171: Add `ConnectionRecoveryHandler` class; automatic reconnect on first connection error is now the default retry handler; pass `CommandInterface` as third argument to retry handler callback (@your-username)
- Enh #1171: Merge `rebindBoundParams()` into `bindPendingParams()`; `bindParam()` bindings are now restored after reconnect (@your-username)
Comment on lines +6 to +7
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
- New #1171: Add `ConnectionRecoveryHandler` class; automatic reconnect on first connection error is now the default retry handler; pass `CommandInterface` as third argument to retry handler callback (@your-username)
- Enh #1171: Merge `rebindBoundParams()` into `bindPendingParams()`; `bindParam()` bindings are now restored after reconnect (@your-username)
- New #1171: Add `ConnectionRecoveryHandler` class; automatic reconnect on first connection error is now the default retry handler; pass `CommandInterface` as third argument to retry handler callback (@bautrukevich)
- Enh #1171: Bind params are now collected and bound before execution (@bautrukevich)


## 2.0.1 February 09, 2026

Expand Down
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.
* @psalm-param Closure(Exception, int, CommandInterface): bool|null $handler
*/
public function setRetryHandler(?Closure $handler): static;

Expand Down
56 changes: 44 additions & 12 deletions src/Driver/Pdo/AbstractPdoCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,20 @@ 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.
*/
public function __construct(PdoConnectionInterface $db)
{
parent::__construct($db);
$this->retryHandler = (new ConnectionRecoveryHandler($db))->asClosure();
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
$this->retryHandler = (new ConnectionRecoveryHandler($db))->asClosure();
$this->retryHandler = (new ConnectionRecoveryHandler())(...);

Is there way to get db connection from the command?

}

/**
Expand All @@ -81,19 +89,16 @@ public function bindParam(
?int $length = null,
mixed $driverOptions = null,
): static {
$this->prepare();

if ($dataType === null) {
$dataType = $this->db->getSchema()->getDataType($value);
}

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

return $this;
}
Expand Down Expand Up @@ -163,7 +168,7 @@ public function prepare(?bool $forRead = null): void
}

/**
* Binds pending parameters registered via {@see bindValue()} and {@see bindValues()}.
* Binds pending parameters registered via {@see bindValue()}, {@see bindValues()} and {@see bindParam()}.
*
* Note that this method requires an active {@see PDOStatement}.
*/
Expand All @@ -172,6 +177,27 @@ protected function bindPendingParams(): void
foreach ($this->params as $name => $value) {
$this->pdoStatement?->bindValue($name, $value->value, $value->type);
}

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);
}

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

protected function getQueryBuilder(): QueryBuilderInterface
Expand All @@ -195,6 +221,9 @@ protected function getQueryMode(int $queryMode): string
/**
* A wrapper around {@see pdoStatementExecute()} to support transactions and retry handlers.
*
* By default uses {@see ConnectionRecoveryHandler} to automatically recover from connection errors on the first
* attempt. Override via {@see setRetryHandler()} to customize retry behavior.
*
* @throws Exception
*/
protected function internalExecute(): void
Expand All @@ -207,9 +236,12 @@ protected function internalExecute(): void
$rawSql ??= $this->getRawSql();
$e = (new ConvertException($e, $rawSql))->run();

if ($this->retryHandler === null || !($this->retryHandler)($e, $attempt)) {
throw $e;
if ($this->retryHandler !== null && ($this->retryHandler)($e, $attempt, $this)) {
continue;
}


throw $e;
}
}
}
Expand Down
66 changes: 66 additions & 0 deletions src/Driver/Pdo/ConnectionRecoveryHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\Driver\Pdo;

use Closure;
use Throwable;
use Yiisoft\Db\Command\CommandInterface;
use Yiisoft\Db\Exception\ConnectionException;
use Yiisoft\Db\Exception\Exception;

/**
* Default retry handler that implements automatic connection recovery on the first execution failure.
*
* Detects {@see ConnectionException} errors and attempts to reconnect and re-prepare the statement before retrying.
* No retry is performed when:
* - it is not the first attempt,
* - the error is not a {@see ConnectionException},
* - a transaction is active (reconnecting would silently roll it back),
* - the reconnection itself fails.
*
* Set as the default {@see AbstractPdoCommand::$retryHandler}.
* Replace it via {@see CommandInterface::setRetryHandler()} to customize retry behavior.
*
* @psalm-type RetryHandlerClosure = Closure(Exception, int, CommandInterface): bool
*/
final class ConnectionRecoveryHandler
{
public function __construct(private readonly PdoConnectionInterface $db) {}

public function __invoke(Exception $e, int $attempt, CommandInterface $command): bool
{
// Only attempt recovery on the first failure.
if ($attempt !== 0 || !$e instanceof ConnectionException) {
return false;
}

// Reconnecting during an active transaction would silently roll it back.
if ($this->db->getTransaction() !== null) {
return false;
}

// Try to renew the connection.
try {
$this->db->close();
$this->db->open();
$command->cancel(); // resets the PDOStatement so prepare() creates a fresh one
} catch (Throwable) {
return false;
}

// Re-prepare the statement against the new connection, restoring all parameter bindings.
$command->prepare();

return true;
}

/**
* @psalm-return RetryHandlerClosure
*/
public function asClosure(): Closure
{
return $this->__invoke(...);
}
Comment on lines +58 to +65
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
/**
* @psalm-return RetryHandlerClosure
*/
public function asClosure(): Closure
{
return $this->__invoke(...);
}

Use (new ConnectionRecoveryHandler())(...)

}
10 changes: 10 additions & 0 deletions src/Exception/ConnectionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\Exception;

/**
* Represents an exception caused by a lost or refused database connection.
*/
final class ConnectionException extends Exception {}
17 changes: 16 additions & 1 deletion src/Exception/ConvertException.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,23 @@
* Converts an exception into a more specific one.
*
* For example, if an exception is caused by a violation of a unique key constraint, it will be converted into an
* {@see IntegrityException} exception.
* {@see IntegrityException} exception. If caused by a lost or refused connection, it will be converted into a
* {@see ConnectionException}.
*/
final class ConvertException
{
private const MSG_INTEGRITY_EXCEPTION_1 = 'SQLSTATE[23';
private const MGS_INTEGRITY_EXCEPTION_2 = 'ORA-00001: unique constraint';
private const MSG_INTEGRITY_EXCEPTION_3 = 'SQLSTATE[HY';

private const MSG_CONNECTION_EXCEPTIONS = [
'no connection',
'General error: 7',
'gone away',
'Connection refused',
'Lost connection',
];

public function __construct(
private readonly \Exception $e,
private readonly string $rawSql,
Expand All @@ -36,6 +45,12 @@ public function run(): Exception

$errorInfo = $this->e instanceof PDOException ? $this->e->errorInfo : null;

foreach (self::MSG_CONNECTION_EXCEPTIONS as $pattern) {
if (str_contains($message, $pattern)) {
return new ConnectionException($message, $errorInfo, $this->e);
}
}

return match (
str_contains($message, self::MSG_INTEGRITY_EXCEPTION_1)
|| str_contains($message, self::MGS_INTEGRITY_EXCEPTION_2)
Expand Down
Loading
Loading