Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,18 @@ DB_PERSISTENT=false
DB_POOL_HEARTBEAT=10
DB_POOL_MAX_IDLE_TIME=60

# Pooled connections are recycled after this many seconds (default 300,
# 0 disables). PDO statements destroyed while a pooled connection is busy in
# another coroutine leak server-side (mysqlnd skips COM_STMT_CLOSE and never
# retries), so recycling bounds what any one connection can accumulate.
# Set via the 'max_lifetime' key of the connection's 'pool' config array.
#
# The Worker also collects each request's cycle garbage BEFORE its pooled
# connections re-enter the pool, which prevents the leak for statements the
# request left behind. Garbage abandoned by one coroutine can still be
# collected while another coroutine's connection is busy — rare, and
# max_lifetime recycling bounds that residue; it cannot be eliminated.

# Redis persistent sockets are unsafe for request-scoped coroutine managers.
REDIS_PERSISTENT=false
REDIS_SESSION_PERSISTENT=false
Expand Down
13 changes: 12 additions & 1 deletion src/Swoole/Database/DatabaseManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ protected function getPool($name)
'wait_timeout' => 3.0,
'heartbeat' => -1,
'max_idle_time' => 60.0,
'max_lifetime' => DatabasePool::DEFAULT_MAX_LIFETIME,
];

$this->pools[$name] = new DatabasePool(
Expand Down Expand Up @@ -119,6 +120,16 @@ protected function syncApplication(): void
}
}

/**
* Release this coroutine's pooled connections immediately.
*
* The Worker no longer uses this: it detaches connections, destroys the
* request's object graph, and only then releases, so statements held in
* cycle garbage cannot be destroyed while the connection is busy in
* another coroutine (which leaks the server-side prepared statement).
* Calling this mid-request re-pools connections without that ordering —
* only use it when no statement from this coroutine can still be alive.
*/
public function releaseConnections()
{
if (!Context::inCoroutine()) {
Expand All @@ -130,7 +141,7 @@ public function releaseConnections()
foreach ($allContext as $key => $value) {
if (str_ends_with($key, '.pool')) {
// Get the connection
$connectionKey = str_replace('.pool', '', $key);
$connectionKey = substr($key, 0, -strlen('.pool'));
$connection = Context::get($connectionKey);

if ($connection && $value instanceof DatabasePool) {
Expand Down
111 changes: 107 additions & 4 deletions src/Swoole/Database/DatabasePool.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@
*/
class DatabasePool
{
/**
* Default number of seconds a pooled connection lives before it is
* recycled. Recycling frees any server-side prepared statements the
* connection has leaked (see release()).
*/
public const DEFAULT_MAX_LIFETIME = 300.0;

protected Channel $channel;
protected int $currentConnections = 0;
protected array $config;
protected string $name;
protected ConnectionFactory $factory;
protected array $connectionConfig;
protected array $idleSince = [];
protected array $createdAt = [];
protected ?int $idlePruneTimerId = null;

/**
Expand Down Expand Up @@ -154,6 +162,24 @@ public function release($connection): void
return;
}

// Recycle connections past max_lifetime instead of re-pooling them.
// PDO statements destroyed while a connection is busy in another
// coroutine leak server-side (mysqlnd skips COM_STMT_CLOSE on a busy
// connection and never retries), so any long-lived pooled connection
// accumulates leaked prepared statements. Closing it frees them all.
// Transactions are rolled back explicitly first: disconnect() only
// drops the Connection's PDO reference, and a leaked statement can
// keep the PDO (and its row locks) alive until a future gc run.
if ($this->hasOutlivedMaxLifetime($connection)) {
$this->markBorrowed($connection);
$this->rollBackAbandonedTransactions($connection);
$this->closeConnection($connection);
$this->currentConnections--;
$this->pruneIdleConnections();

return;
}

try {
$this->resetConnection($connection);
$this->markIdle($connection);
Expand Down Expand Up @@ -184,13 +210,19 @@ public function release($connection): void
public function pruneIdleConnections(?float $now = null): int
{
$maxIdleTime = (float) ($this->config['max_idle_time'] ?? 60.0);
$minConnections = (int) ($this->config['min_connections'] ?? 1);

// Idle pruning respects min_connections; lifetime pruning does not,
// since an over-age connection carries leaked server-side prepared
// statements that only closing it can free. The pool refills on demand.
$idlePruningActive = $maxIdleTime > 0 && $this->currentConnections > $minConnections;
$lifetimePruningActive = ((float) ($this->config['max_lifetime'] ?? self::DEFAULT_MAX_LIFETIME)) > 0;

if ($maxIdleTime <= 0 || $this->currentConnections <= ($this->config['min_connections'] ?? 1)) {
if (! $idlePruningActive && ! $lifetimePruningActive) {
return 0;
}

$now ??= microtime(true);
$minConnections = (int) ($this->config['min_connections'] ?? 1);
$available = $this->channel->length();
$kept = [];
$closed = 0;
Expand All @@ -206,7 +238,11 @@ public function pruneIdleConnections(?float $now = null): int
$idleSince = $this->idleSince[$connectionId] ?? $now;
$idleFor = $now - $idleSince;

if ($this->currentConnections > $minConnections && $idleFor >= $maxIdleTime) {
$idleExpired = $maxIdleTime > 0
&& $this->currentConnections > $minConnections
&& $idleFor >= $maxIdleTime;

if ($idleExpired || $this->hasOutlivedMaxLifetime($connection, $now)) {
unset($this->idleSince[$connectionId]);
$this->closeConnection($connection);
$this->currentConnections--;
Expand All @@ -219,7 +255,13 @@ public function pruneIdleConnections(?float $now = null): int
}

foreach ($kept as $connection) {
$this->channel->push($connection, 0.001);
if (! $this->channel->push($connection, 0.001)) {
error_log('⚠️ Could not re-pool a kept connection - closing it instead');
$this->markBorrowed($connection);
$this->closeConnection($connection);
$this->currentConnections--;
$closed++;
}
}

return $closed;
Expand Down Expand Up @@ -302,6 +344,10 @@ protected function resetConnection($connection): void
*/
protected function closeConnection($connection): void
{
if (is_object($connection)) {
unset($this->createdAt[spl_object_id($connection)]);
}

try {
if ($connection instanceof Connection) {
$connection->disconnect();
Expand All @@ -311,6 +357,54 @@ protected function closeConnection($connection): void
}
}

/**
* Roll back any transaction left open on a connection about to be closed.
*
* @see release() for why closing alone is not enough.
*/
protected function rollBackAbandonedTransactions($connection): void
{
if (! $connection instanceof Connection) {
return;
}

try {
if ($connection->transactionLevel() > 0) {
$connection->rollBack(0);
}

$pdo = $connection->getPdo();

if ($pdo && $pdo->inTransaction()) {
$pdo->rollBack();
}

$connection->unsetTransactionManager();
} catch (Throwable $e) {
error_log('⚠️ Could not roll back before recycling expired connection: '.$e->getMessage());
}
}

/**
* Whether a connection is older than the pool's max_lifetime.
*
* A value of 0 or less disables lifetime recycling. Connections without a
* recorded creation time are treated as brand new.
*/
protected function hasOutlivedMaxLifetime($connection, ?float $now = null): bool
{
$maxLifetime = (float) ($this->config['max_lifetime'] ?? self::DEFAULT_MAX_LIFETIME);

if ($maxLifetime <= 0 || ! is_object($connection)) {
return false;
}

$now ??= microtime(true);
$createdAt = $this->createdAt[spl_object_id($connection)] ?? $now;

return ($now - $createdAt) >= $maxLifetime;
}

/**
* Create a new database connection
*/
Expand All @@ -321,6 +415,10 @@ protected function createConnection()
try {
$connection = $this->factory->make($this->connectionConfig, $this->name);

if (is_object($connection)) {
$this->createdAt[spl_object_id($connection)] = microtime(true);
}

if ($connection instanceof Connection) {
// Without a reconnector, Connection::reconnect() throws
// LostConnectionException and the pool silently discards and
Expand All @@ -331,6 +429,10 @@ protected function createConnection()

$connection->setPdo($fresh->getRawPdo())
->setReadPdo($fresh->getRawReadPdo());

// The server session is brand new, so restart the
// max_lifetime clock along with it.
$this->createdAt[spl_object_id($connection)] = microtime(true);
});
}

Expand Down Expand Up @@ -419,6 +521,7 @@ public function getStats(): array
'max_connections' => $this->config['max_connections'] ?? 10,
'min_connections' => $this->config['min_connections'] ?? 1,
'max_idle_time' => $this->config['max_idle_time'] ?? 60.0,
'max_lifetime' => $this->config['max_lifetime'] ?? self::DEFAULT_MAX_LIFETIME,
];
}

Expand Down
Loading
Loading