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
4 changes: 3 additions & 1 deletion src/boost/docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ To see how read / write connections should be configured, let's look at this exa
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'heartbeat_timeout' => 1.0,
'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60),
],
],
Expand Down Expand Up @@ -144,12 +145,13 @@ Each connection may define its own `pool` configuration:
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'heartbeat_timeout' => 1.0,
'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60),
],
],
```

The `min_connections` option determines the minimum number of connections kept in the pool, while the `max_connections` option determines the maximum number of connections that may be opened for the worker. The `connect_timeout` option controls how long Hypervel will wait while opening a new database connection. The `wait_timeout` option controls how long a coroutine may wait for an available connection when the pool is exhausted. The `heartbeat` option controls how often idle pooled connections are pinged to keep them alive and detect broken sockets; set this value to `-1` to disable heartbeats. The `max_idle_time` option controls how long an idle connection may remain in the pool before it is closed.
The `min_connections` option determines the minimum number of connections kept warm in the pool, while the `max_connections` option determines the maximum number of connections that may be opened for the worker. The `connect_timeout` option controls how long Hypervel will wait while opening a new database connection. The `wait_timeout` option controls how long a coroutine may wait for an available connection when the pool is exhausted. The `heartbeat` option controls how often Hypervel validates idle pooled connections; set this value to `-1` to disable heartbeats. When heartbeats are enabled, Hypervel keeps the minimum idle connection set alive with a raw `SELECT 1` ping that does not fire query events, query logs, or query duration handlers. The `heartbeat_timeout` option controls how long a heartbeat ping may run before the connection is discarded. The `max_idle_time` option controls how long idle connections above the minimum pool size may remain in the pool before they are closed.

Hypervel's default database configuration also includes a `pgsql-pooled` connection. This connection is intended for PostgreSQL transaction poolers such as PgBouncer and uses separate `DB_POOLED_*` environment variables. It also sets `migrations_connection` to `pgsql`, allowing your application to use the pooled connection at runtime while migration commands use the direct PostgreSQL connection.

Expand Down
2 changes: 1 addition & 1 deletion src/boost/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

## Database

- Wire opt-in heartbeat support for database pools. `src/foundation/config/database.php` and the database docs advertise a `heartbeat` option in every database pool block, but `Hypervel\Database\Pool\PooledConnection` implements `Hypervel\Contracts\Pool\ConnectionInterface` directly and never consumes `PoolOption::getHeartbeat()`. Do not switch `PooledConnection` wholesale to `Hypervel\Pool\KeepaliveConnection`: that class uses a different `call()`-based lifecycle, makes `getConnection()` throw, stores the wrapped connection in a one-slot channel, treats `heartbeat <= 0` as a 10-second interval, and would bypass existing DB-specific release behavior such as state reset, transaction rollback, error-count handling, release events, and shared in-memory SQLite handling. Correct fix: keep `heartbeat => -1` as disabled with zero timer / ping overhead; when `heartbeat > 0`, have each worker-local `DbPool` start one timer for that pool, inspect only idle pooled connections, skip borrowed connections, close connections older than `max_idle_time`, and run a lightweight raw PDO ping such as `SELECT 1` on remaining idle connections without firing query events or mutating query logs / query-duration state. If the ping fails, close / discard the pooled connection so the next borrow creates a fresh connection. This is useful for long-lived workers behind load balancers, firewalls, NAT, or managed database proxies that drop idle TCP connections.
- Add max-lifetime recycling support for pooled database connections. Heartbeat keeps minimum idle connections validated for the worker lifetime, but rotating database poolers and managed proxies may still benefit from periodically replacing otherwise healthy long-lived sockets. Correct fix: add an opt-in pool option that recycles idle pooled DB connections older than the configured lifetime without affecting borrowed connections or normal query execution.

## Foundation

Expand Down
7 changes: 6 additions & 1 deletion src/contracts/src/Engine/CoroutineInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,15 @@ public static function defer(callable $callable): void;
public static function yield(): bool;

/**
* Resume the coroutine by coroutine Id.
* Resume the coroutine by coroutine ID.
*/
public static function resumeById(int $id): bool;

/**
* Cancel the coroutine by coroutine ID.
*/
public static function cancelById(int $id, bool $throwException = false): bool;

/**
* Get the coroutine stats.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/contracts/src/Pool/PoolOptionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ public function getWaitTimeout(): float;
*/
public function getHeartbeat(): float;

/**
* Get the heartbeat timeout in seconds.
*/
public function getHeartbeatTimeout(): float;

/**
* Get the maximum idle time in seconds before a connection is closed.
*/
Expand Down
3 changes: 3 additions & 0 deletions src/database/src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,9 @@ public function resetForPool(): void

// Reset record modification state
$this->recordsModified = false;

// Reset execution errors for the next borrow window.
$this->errorCount = 0;
}

/**
Expand Down
152 changes: 152 additions & 0 deletions src/database/src/Pool/DbPool.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
namespace Hypervel\Database\Pool;

use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\Log\StdoutLoggerInterface;
use Hypervel\Contracts\Pool\ConnectionInterface;
use Hypervel\Coordinator\Timer;
use Hypervel\Pool\Frequency;
use Hypervel\Pool\Pool;
use Hypervel\Support\Arr;
use InvalidArgumentException;
use PDO;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* Database connection pool.
Expand All @@ -26,6 +30,12 @@ class DbPool extends Pool
{
protected array $config;

protected ?Timer $heartbeatTimer = null;

protected ?int $heartbeatTimerId = null;

protected int $heartbeatGeneration = 0;

/**
* Shared PDO for in-memory SQLite. All pool slots must share the same PDO
* instance, otherwise each would get its own empty database.
Expand All @@ -52,11 +62,23 @@ public function __construct(Container $container, string $name)

parent::__construct($container, $name, $poolOptions);

$this->heartbeatTimer = new Timer($this->getLogger());

// For in-memory SQLite, pre-create a shared PDO so all pool slots
// see the same database. This must happen after parent::__construct.
if ($this->isInMemorySqlite()) {
$this->sharedInMemorySqlitePdo = $this->createSharedInMemorySqlitePdo();
}

$this->startHeartbeat();
}

/**
* Destroy the database pool.
*/
public function __destruct()
{
$this->clearHeartbeat();
}

/**
Expand Down Expand Up @@ -111,7 +133,137 @@ protected function isInMemorySqlite(): bool
*/
public function flushAll(): void
{
$this->clearHeartbeat();

parent::flushAll();
$this->sharedInMemorySqlitePdo = null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Start the heartbeat timer if configured.
*/
protected function startHeartbeat(): void
{
if ($this->heartbeatTimer === null || $this->option->getHeartbeat() <= 0 || $this->sharedInMemorySqlitePdo !== null) {
return;
}

$this->heartbeatTimerId = $this->heartbeatTimer->tick(
$this->option->getHeartbeat(),
function (bool $isClosing): ?string {
if ($isClosing) {
return Timer::STOP;
}

$this->heartbeat();

return null;
}
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Clear the heartbeat timer.
*/
protected function clearHeartbeat(): void
{
++$this->heartbeatGeneration;

if ($this->heartbeatTimer === null || $this->heartbeatTimerId === null) {
return;
}

$this->heartbeatTimer->clear($this->heartbeatTimerId);
$this->heartbeatTimerId = null;
}

/**
* Run one heartbeat sweep over currently idle connections.
*/
protected function heartbeat(): void
{
$connectionsToInspect = $this->getConnectionsInChannel();

for ($i = 0; $i < $connectionsToInspect; ++$i) {
$connection = $this->channel->pop(0.001);

if (! $connection instanceof PooledConnection) {
break;
}

$this->heartbeatConnection($connection);
}
}

/**
* Heartbeat one idle connection.
*/
protected function heartbeatConnection(PooledConnection $connection): void
{
try {
if ($connection->isIdleExpired() && $this->currentConnections > $this->option->getMinConnections()) {
$this->discardHeartbeatConnection($connection);

return;
}

$heartbeatGeneration = $this->heartbeatGeneration;

if ($connection->ping($this->option->getHeartbeatTimeout())) {
if ($heartbeatGeneration === $this->heartbeatGeneration) {
$this->release($connection);
} else {
$this->discardHeartbeatConnection($connection);
}

return;
}

$this->discardHeartbeatConnection($connection);
} catch (Throwable $exception) {
$this->logHeartbeatError('Database heartbeat failed: ' . $exception);
$this->discardHeartbeatConnection($connection);
}
}

/**
* Discard an idle connection from the pool.
*/
protected function discardHeartbeatConnection(PooledConnection $connection): void
{
--$this->currentConnections;

try {
if ($connection->hasOpenTransaction()) {
$this->logHeartbeatError('Database heartbeat found an idle connection with an open transaction.');
}

$connection->close();
} catch (Throwable $exception) {
$this->logHeartbeatError('Database heartbeat close failed: ' . $exception);
}
}

/**
* Log a heartbeat error without breaking pool cleanup.
*/
protected function logHeartbeatError(string $message): void
{
try {
$this->getLogger()?->error($message);
} catch (Throwable) {
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/**
* Get the logger instance if available.
*/
protected function getLogger(): ?LoggerInterface
{
if (! $this->container->has(StdoutLoggerInterface::class)) {
return null;
}

return $this->container->make(StdoutLoggerInterface::class);
}
}
Loading