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
14 changes: 9 additions & 5 deletions src/boost/docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,10 @@ To see how read / write connections should be configured, let's look at this exa
'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'heartbeat_timeout' => 1.0,
'heartbeat' => (float) env('DB_HEARTBEAT', -1),
'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0),
'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60),
'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1),
],
],
```
Expand Down Expand Up @@ -144,14 +145,17 @@ Each connection may define its own `pool` configuration:
'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'heartbeat_timeout' => 1.0,
'heartbeat' => (float) env('DB_HEARTBEAT', -1),
'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0),
'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60),
'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1),
],
],
```

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.
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 connections in the worker pool; 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. The `max_lifetime` option controls how long a pooled connection generation may live before it is recycled while idle or before it is reused; set this value to `-1` to disable lifetime recycling.

Heartbeat and max lifetime recycling apply to Hypervel's worker pool whether the connection points directly at the database or through a proxy / pooler. They help long-running workers avoid stale sockets and rotate old idle connection generations before those connections are used by a request.

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
3 changes: 2 additions & 1 deletion src/boost/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

## Database

- 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.
- Consider built-in early jitter for DB pool `max_lifetime`. Exact max-lifetime recycling can make a burst-created cohort of idle connections expire in the same heartbeat tick, causing synchronized reconnects. A clean design would avoid an extra config knob and assign each connection an effective lifetime between 90-100% of `max_lifetime`, so the configured value remains the upper bound while reconnects are spread out.

## Foundation

Expand All @@ -52,6 +52,7 @@
## Pool

- Make `Hypervel\Pool\KeepaliveConnection` honor disabled heartbeat configuration. `PoolOption` documents `heartbeat => -1` as disabled, but `KeepaliveConnection::getHeartbeatSeconds()` currently turns any non-positive heartbeat into a 10-second interval and `addHeartbeat()` always creates a timer. Correct fix: only create the heartbeat timer when `PoolOption::getHeartbeat() > 0`; when heartbeat is `<= 0`, do not start a timer or run heartbeat work. Keep `max_idle_time` behavior separate from heartbeat.
- Add Redis pool heartbeat and max-lifetime support. Redis pools expose a `heartbeat` config key today, but `Hypervel\Redis\RedisPool` extends the base pool and no Redis code consumes `PoolOption::getHeartbeat()` or starts a heartbeat timer. Correct fix: add opt-in Redis heartbeat and max-lifetime recycling for idle pooled Redis connections, keep both disabled by default, and test that borrowed connections are never recycled.

## Routing

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 @@ -41,6 +41,11 @@ public function getHeartbeatTimeout(): float;
*/
public function getMaxIdleTime(): float;

/**
* Get the maximum lifetime in seconds before a connection is recycled.
*/
public function getMaxLifetime(): float;

/**
* Get the events to trigger on connection lifecycle.
*/
Expand Down
10 changes: 9 additions & 1 deletion src/database/src/Pool/DbPool.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,15 @@ protected function heartbeat(): void
protected function heartbeatConnection(PooledConnection $connection): void
{
try {
if ($connection->isIdleExpired() && $this->currentConnections > $this->option->getMinConnections()) {
$now = microtime(true);

if ($connection->isLifetimeExpired($now)) {
$this->discardHeartbeatConnection($connection);

return;
}
Comment on lines +206 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Lifetime discard has no min-connections guard

The idle-expiry branch two lines below requires $this->currentConnections > $this->option->getMinConnections() before discarding, preserving at least the minimum pool size. The lifetime-expiry branch added here has no such guard. When a cohort of connections all share the same birth time (typical at worker startup), every heartbeat tick can discard all idle connections at once, momentarily dropping the pool to zero even if min_connections > 1. The next in-bound request then bears the reconnection cost. The todo.md entry on jitter acknowledges this, but noting the asymmetry with the idle guard may help reviewers understand why the behaviour can be surprising without changing configuration.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks. I am intentionally not adding a min_connections guard to the lifetime branch. max_lifetime is an absolute generation cap, so keeping expired connections only to preserve the idle minimum would defeat the setting. The reconnect-wave concern is valid, and src/boost/todo.md tracks jitter as the clean follow-up because that smooths expiry timing without weakening the lifetime guarantee.


if ($connection->isIdleExpired($now) && $this->currentConnections > $this->option->getMinConnections()) {
$this->discardHeartbeatConnection($connection);

return;
Expand Down
55 changes: 49 additions & 6 deletions src/database/src/Pool/PooledConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class PooledConnection implements PoolConnectionInterface

protected float $lastReleaseTime = 0.0;

protected float $createdAt = 0.0;

protected bool $availableForReuse = false;

protected bool $invalid = false;

protected ?Dispatcher $dispatcher = null;
Expand Down Expand Up @@ -78,6 +82,8 @@ public function getConnection(): Connection
public function getActiveConnection(): Connection
{
if ($this->check()) {
$this->availableForReuse = false;

return $this->connection;
}

Expand Down Expand Up @@ -133,7 +139,10 @@ public function reconnect(): bool
);
}

$this->lastUseTime = microtime(true);
$now = microtime(true);
$this->lastUseTime = $now;
$this->createdAt = $now;
$this->availableForReuse = false;
$this->markValid();

return true;
Expand All @@ -152,14 +161,23 @@ public function check(): bool
return false;
}

$maxIdleTime = $this->pool->getOption()->getMaxIdleTime();
$now = microtime(true);

if ($now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) {
return false;
}
if ($this->availableForReuse) {
// Time-based recycling is a reuse rule; it must not replace a connection
// while the borrowed wrapper may still hold transaction state.
if ($this->isLifetimeExpired($now)) {
return false;
}

$this->lastUseTime = $now;
$maxIdleTime = $this->pool->getOption()->getMaxIdleTime();

if ($now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) {
return false;
}

$this->lastUseTime = $now;
}

return true;
}
Expand Down Expand Up @@ -270,6 +288,7 @@ public function release(): void
// Mark as stale so it will be recreated
$this->markInvalid();
} finally {
$this->availableForReuse = true;
$this->pool->release($this);
}
}
Expand All @@ -290,6 +309,28 @@ public function getLastReleaseTime(): float
return $this->lastReleaseTime;
}

/**
* Get the connection generation creation time.
*/
public function getCreatedAt(): float
{
return $this->createdAt;
}

/**
* Determine if this connection generation has reached its maximum lifetime.
*/
public function isLifetimeExpired(?float $now = null): bool
{
$maxLifetime = $this->pool->getOption()->getMaxLifetime();

if ($maxLifetime <= 0) {
return false;
}

return ($now ?? microtime(true)) >= $this->createdAt + $maxLifetime;
}

/**
* Determine if the underlying connection has an open transaction.
*/
Expand Down Expand Up @@ -399,5 +440,7 @@ protected function refresh(Connection $connection): void
new ConnectionEstablished($connection)
);
}

$this->createdAt = microtime(true);
}
}
24 changes: 14 additions & 10 deletions src/foundation/config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
|--------------------------------------------------------------------------
|
| Database connections may define a "pool" array for long-lived workers.
| Heartbeats are disabled with -1; positive values keep the minimum
| idle connections validated without firing query events or logs.
| Heartbeats validate idle connections, while max lifetime recycling
| rotates old idle connection generations before they are reused.
|
*/

Expand Down Expand Up @@ -79,9 +79,10 @@
'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'heartbeat_timeout' => 1.0,
'heartbeat' => (float) env('DB_HEARTBEAT', -1),
'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0),
'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60),
'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1),
],
],

Expand All @@ -108,9 +109,10 @@
'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'heartbeat_timeout' => 1.0,
'heartbeat' => (float) env('DB_HEARTBEAT', -1),
'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0),
'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60),
'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1),
],
],

Expand All @@ -135,9 +137,10 @@
'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'heartbeat_timeout' => 1.0,
'heartbeat' => (float) env('DB_HEARTBEAT', -1),
'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0),
'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60),
'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1),
],
],

Expand All @@ -163,9 +166,10 @@
'max_connections' => (int) env('DB_POOLED_MAX_CONNECTIONS', 20),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'heartbeat_timeout' => 1.0,
'heartbeat' => (float) env('DB_POOLED_HEARTBEAT', -1),
'heartbeat_timeout' => (float) env('DB_POOLED_HEARTBEAT_TIMEOUT', 1.0),
'max_idle_time' => (float) env('DB_POOLED_MAX_IDLE_TIME', 60),
'max_lifetime' => (float) env('DB_POOLED_MAX_LIFETIME', -1),
],
],
],
Expand Down
1 change: 1 addition & 0 deletions src/pool/src/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ protected function initOption(array $options = []): void
heartbeat: $options['heartbeat'] ?? -1,
heartbeatTimeout: $options['heartbeat_timeout'] ?? 1.0,
maxIdleTime: $options['max_idle_time'] ?? 60.0,
maxLifetime: $options['max_lifetime'] ?? -1.0,
events: $options['events'] ?? [],
);
}
Expand Down
24 changes: 24 additions & 0 deletions src/pool/src/PoolOption.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class PoolOption implements PoolOptionInterface
* @param float $heartbeat Heartbeat interval in seconds (-1 to disable)
* @param float $heartbeatTimeout Heartbeat timeout in seconds
* @param float $maxIdleTime Maximum idle time in seconds before connection is closed
* @param float $maxLifetime Maximum lifetime in seconds before connection is recycled (-1 to disable)
* @param array<int, string> $events Events to trigger on connection lifecycle
*/
public function __construct(
Expand All @@ -29,6 +30,7 @@ public function __construct(
private float $heartbeat = -1,
private float $heartbeatTimeout = 1.0,
private float $maxIdleTime = 60.0,
private float $maxLifetime = -1.0,
private array $events = [],
) {
}
Expand Down Expand Up @@ -166,6 +168,28 @@ public function setMaxIdleTime(float $maxIdleTime): static
return $this;
}

/**
* Get the maximum lifetime in seconds before a connection is recycled.
*/
public function getMaxLifetime(): float
{
return $this->maxLifetime;
}

/**
* Set the maximum lifetime in seconds before a connection is recycled.
*
* Boot-only. The value persists on the worker-lifetime pool option and is
* read by every subsequent pool operation. Per-request use races across
* coroutines.
*/
public function setMaxLifetime(float $maxLifetime): static
{
$this->maxLifetime = $maxLifetime;

return $this;
}

public function getEvents(): array
{
return $this->events;
Expand Down
Loading