From 37e21a63019356f32abe1c14dcfbe1d91f6e2e0b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:29:57 +0000 Subject: [PATCH 1/6] feat(pool): add max lifetime option Add max_lifetime support to the shared pool option contract and implementation. Parse the new option from pool configuration with a disabled-by-default value of -1.0, matching the existing heartbeat disabled convention. Expose a boot-time setter for tests and startup configuration while preserving worker-lifetime safety guidance. --- .../src/Pool/PoolOptionInterface.php | 5 ++++ src/pool/src/Pool.php | 1 + src/pool/src/PoolOption.php | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/src/contracts/src/Pool/PoolOptionInterface.php b/src/contracts/src/Pool/PoolOptionInterface.php index 62a0c4c8e..1cc1bd5ec 100644 --- a/src/contracts/src/Pool/PoolOptionInterface.php +++ b/src/contracts/src/Pool/PoolOptionInterface.php @@ -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. */ diff --git a/src/pool/src/Pool.php b/src/pool/src/Pool.php index 65c622dfc..7f762b302 100644 --- a/src/pool/src/Pool.php +++ b/src/pool/src/Pool.php @@ -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'] ?? [], ); } diff --git a/src/pool/src/PoolOption.php b/src/pool/src/PoolOption.php index 7606271b3..2965b9ffc 100644 --- a/src/pool/src/PoolOption.php +++ b/src/pool/src/PoolOption.php @@ -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 $events Events to trigger on connection lifecycle */ public function __construct( @@ -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 = [], ) { } @@ -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; From b5aabfcdf7ccbb506208471d845490a171245904 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:30:12 +0000 Subject: [PATCH 2/6] feat(database): recycle expired pooled connections Track the creation time for each pooled database connection generation. Reject expired generations during the normal reuse check so a returned connection is reconnected before user code receives it, even when heartbeat is disabled. Have the database heartbeat discard lifetime-expired idle connections before idle trimming or ping validation, while borrowed connections remain untouched. --- src/database/src/Pool/DbPool.php | 10 +++++- src/database/src/Pool/PooledConnection.php | 37 ++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/database/src/Pool/DbPool.php b/src/database/src/Pool/DbPool.php index d1e583ed6..e06afc54c 100644 --- a/src/database/src/Pool/DbPool.php +++ b/src/database/src/Pool/DbPool.php @@ -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; + } + + if ($connection->isIdleExpired($now) && $this->currentConnections > $this->option->getMinConnections()) { $this->discardHeartbeatConnection($connection); return; diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index 384d6f79b..8bbfcef7f 100644 --- a/src/database/src/Pool/PooledConnection.php +++ b/src/database/src/Pool/PooledConnection.php @@ -45,6 +45,8 @@ class PooledConnection implements PoolConnectionInterface protected float $lastReleaseTime = 0.0; + protected float $createdAt = 0.0; + protected bool $invalid = false; protected ?Dispatcher $dispatcher = null; @@ -133,7 +135,9 @@ public function reconnect(): bool ); } - $this->lastUseTime = microtime(true); + $now = microtime(true); + $this->lastUseTime = $now; + $this->createdAt = $now; $this->markValid(); return true; @@ -152,9 +156,14 @@ public function check(): bool return false; } - $maxIdleTime = $this->pool->getOption()->getMaxIdleTime(); $now = microtime(true); + if ($this->isLifetimeExpired($now)) { + return false; + } + + $maxIdleTime = $this->pool->getOption()->getMaxIdleTime(); + if ($now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) { return false; } @@ -290,6 +299,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. */ @@ -399,5 +430,7 @@ protected function refresh(Connection $connection): void new ConnectionEstablished($connection) ); } + + $this->createdAt = microtime(true); } } From e46e363f30f0065f978d6f4e920402857242e02d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:30:27 +0000 Subject: [PATCH 3/6] docs(database): expose pool lifetime settings Add max_lifetime and env-driven heartbeat timeout examples to the database pool configuration. Document how heartbeat and max lifetime work together for long-running workers, direct database connections, and proxy or pooler setups. Keep DB_POOLED_* settings separate for the pgsql-pooled runtime connection. --- src/boost/docs/database.md | 14 +++++++++----- src/foundation/config/database.php | 24 ++++++++++++++---------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/boost/docs/database.md b/src/boost/docs/database.md index 14e73a03b..0c691246c 100644 --- a/src/boost/docs/database.md +++ b/src/boost/docs/database.md @@ -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), ], ], ``` @@ -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. diff --git a/src/foundation/config/database.php b/src/foundation/config/database.php index 60a304456..c6d573e7c 100644 --- a/src/foundation/config/database.php +++ b/src/foundation/config/database.php @@ -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. | */ @@ -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), ], ], @@ -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), ], ], @@ -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), ], ], @@ -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), ], ], ], From 0241c3e5ebb60ff5f0f55aaad251b1388fef014d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:30:34 +0000 Subject: [PATCH 4/6] test(database): cover pool max lifetime recycling Cover enabled and disabled database pool max lifetime behavior. Assert expired returned connections reconnect before reuse, heartbeat discards lifetime-expired idle connections before pinging, and borrowed connections are not recycled by heartbeat. Add focused PoolOption coverage for the new max lifetime option API. --- .../Database/PooledConnectionTest.php | 86 +++++++++++++++++++ .../Database/Sqlite/DbPoolHeartbeatTest.php | 82 ++++++++++++++++++ tests/Pool/PoolOptionTest.php | 33 +++++++ 3 files changed, 201 insertions(+) create mode 100644 tests/Pool/PoolOptionTest.php diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 5c25dbb33..e131198cf 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -41,7 +41,9 @@ protected function defineEnvironment(ApplicationContract $app): void 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, 'heartbeat' => -1, + 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, + 'max_lifetime' => -1.0, ], ]); } @@ -287,6 +289,85 @@ public function testInvalidConnectionReconnectsEvenWithFreshReleaseTime(): void $this->assertNotSame($originalConnection, $pooledConnection->getActiveConnection()); } + public function testExpiredLifetimeReconnectsBeforeReuseEvenWithoutHeartbeat(): void + { + $this->app->make('config')->set('database.connections.pool_test.pool.max_lifetime', 1.0); + + $pool = new DbPool($this->app, 'pool_test'); + $pooledConnection = $this->createPooledConnection($pool); + $originalConnection = $pooledConnection->getConnection(); + + $this->assertSame(1.0, $pool->getOption()->getMaxLifetime()); + + $this->ageConnectionGeneration($pooledConnection); + + $this->assertFalse($pooledConnection->check()); + $this->assertNotSame($originalConnection, $pooledConnection->getActiveConnection()); + } + + public function testExpiredLifetimeReconnectsWhenBorrowedFromPoolAgainWithoutHeartbeat(): void + { + $this->app->make('config')->set('database.connections.pool_test.pool.max_lifetime', 1.0); + + $pool = new DbPool($this->app, 'pool_test'); + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $originalConnection = $pooledConnection->getConnection(); + $pooledConnection->release(); + + $this->ageConnectionGeneration($pooledConnection); + + /** @var PooledConnection $nextPooledConnection */ + $nextPooledConnection = $pool->get(); + + $this->assertSame($pooledConnection, $nextPooledConnection); + $this->assertNotSame($originalConnection, $nextPooledConnection->getConnection()); + + $nextPooledConnection->release(); + } + + public function testDisabledMaxLifetimeDoesNotRecycleAgedConnectionGeneration(): void + { + $pool = new DbPool($this->app, 'pool_test'); + $pooledConnection = $this->createPooledConnection($pool); + $originalConnection = $pooledConnection->getConnection(); + + $this->assertSame(-1.0, $pool->getOption()->getMaxLifetime()); + + $this->ageConnectionGeneration($pooledConnection); + + $this->assertFalse($pooledConnection->isLifetimeExpired()); + $this->assertTrue($pooledConnection->check()); + $this->assertSame($originalConnection, $pooledConnection->getActiveConnection()); + } + + public function testPingDoesNotExtendConnectionLifetime(): void + { + $pool = new DbPool($this->app, 'pool_test'); + $pooledConnection = $this->createPooledConnection($pool); + $pooledConnection->getConnection()->getPdo(); + + $createdAt = $pooledConnection->getCreatedAt(); + + $this->assertTrue($pooledConnection->ping(1.0)); + $this->assertSame($createdAt, $pooledConnection->getCreatedAt()); + } + + public function testConnectionRefreshResetsLifetime(): void + { + $pool = new DbPool($this->app, 'pool_test'); + $pooledConnection = $this->createPooledConnection($pool); + $connection = $pooledConnection->getConnection(); + + $this->ageConnectionGeneration($pooledConnection); + $expiredAt = $pooledConnection->getCreatedAt(); + + $connection->reconnect(); + + $this->assertGreaterThan($expiredAt, $pooledConnection->getCreatedAt()); + } + public function testReleaseSnapshotsErrorCountBeforeResettingConnection(): void { $pool = new DbPool($this->app, 'pool_test'); @@ -440,4 +521,9 @@ private function createPooledConnectionForName(DbPool $pool, string $name): Pool return new PooledConnection($this->app, $pool, $config); } + + private function ageConnectionGeneration(PooledConnection $connection): void + { + (new ReflectionProperty(PooledConnection::class, 'createdAt'))->setValue($connection, microtime(true) - 5.0); + } } diff --git a/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php b/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php index 321a8573e..ed62ca860 100644 --- a/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php +++ b/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php @@ -146,6 +146,62 @@ public function testHeartbeatValidationKeepsMinimumConnectionCheckoutValid(): vo }); } + public function testHeartbeatDiscardsLifetimeExpiredIdleConnectionBeforePinging(): void + { + run(function () { + $pool = $this->createPool([ + 'min_connections' => 1, + 'max_connections' => 1, + 'heartbeat' => -1, + 'max_lifetime' => 1.0, + ], LifetimeExpiredPingTrackingDbPool::class); + + $pooledConnection = $pool->get(); + $this->assertInstanceOf(LifetimeExpiredPingTrackingPooledConnection::class, $pooledConnection); + + $connection = $pooledConnection->getConnection(); + $pooledConnection->release(); + + $this->ageConnectionGeneration($pooledConnection); + + $pool->runHeartbeatForTest(); + + $this->assertFalse($pooledConnection->pingCalled); + $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getConnectionsInChannel()); + + $nextPooledConnection = $pool->get(); + + $this->assertNotSame($connection, $nextPooledConnection->getConnection()); + + $nextPooledConnection->release(); + }); + } + + public function testHeartbeatDoesNotRecycleBorrowedLifetimeExpiredConnection(): void + { + run(function () { + $pool = $this->createPool([ + 'min_connections' => 1, + 'max_connections' => 1, + 'heartbeat' => -1, + 'max_lifetime' => 1.0, + ]); + + $borrowed = $pool->get(); + + $this->ageConnectionGeneration($borrowed); + + $pool->runHeartbeatForTest(); + + $this->assertSame(1, $borrowed->getConnection()->selectOne('SELECT 1 as result')->result); + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getConnectionsInChannel()); + + $borrowed->release(); + }); + } + public function testHeartbeatDoesNotRealizeLazyPdoClosures(): void { run(function () { @@ -353,6 +409,7 @@ protected function createPool(array $poolOptions = [], string $poolClass = Inspe 'heartbeat' => -1, 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, + 'max_lifetime' => -1.0, ...$poolOptions, ], ]); @@ -371,6 +428,11 @@ protected function ageReleasedConnection(PooledConnection $connection): void $lastReleaseTime->setValue($connection, microtime(true) - 5.0); $lastUseTime->setValue($connection, microtime(true) - 5.0); } + + protected function ageConnectionGeneration(PooledConnection $connection): void + { + (new ReflectionProperty(PooledConnection::class, 'createdAt'))->setValue($connection, microtime(true) - 5.0); + } } class InspectableHeartbeatDbPool extends DbPool @@ -404,6 +466,26 @@ public function ping(float $timeout): bool } } +class LifetimeExpiredPingTrackingDbPool extends InspectableHeartbeatDbPool +{ + protected function createConnection(): ConnectionInterface + { + return new LifetimeExpiredPingTrackingPooledConnection($this->container, $this, $this->config); + } +} + +class LifetimeExpiredPingTrackingPooledConnection extends PooledConnection +{ + public bool $pingCalled = false; + + public function ping(float $timeout): bool + { + $this->pingCalled = true; + + return true; + } +} + class FlushingHeartbeatDbPool extends InspectableHeartbeatDbPool { protected function createConnection(): ConnectionInterface diff --git a/tests/Pool/PoolOptionTest.php b/tests/Pool/PoolOptionTest.php new file mode 100644 index 000000000..980b98571 --- /dev/null +++ b/tests/Pool/PoolOptionTest.php @@ -0,0 +1,33 @@ +assertSame(-1.0, $option->getMaxLifetime()); + } + + public function testMaxLifetimeCanBeConfigured(): void + { + $option = new PoolOption(maxLifetime: 120.0); + + $this->assertSame(120.0, $option->getMaxLifetime()); + } + + public function testMaxLifetimeCanBeChanged(): void + { + $option = new PoolOption; + + $this->assertSame($option, $option->setMaxLifetime(30.0)); + $this->assertSame(30.0, $option->getMaxLifetime()); + } +} From fc0da0a6eb297b130e3a59f9e747117cd46d1ad5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:30:45 +0000 Subject: [PATCH 5/6] docs(boost): update pool todo items Remove the completed database max-lifetime recycling todo. Add follow-up notes for DB lifetime jitter and Redis pool heartbeat/max-lifetime support so those decisions are tracked separately from this implementation. --- src/boost/todo.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/boost/todo.md b/src/boost/todo.md index 1f3230bcf..4a6bf0247 100644 --- a/src/boost/todo.md +++ b/src/boost/todo.md @@ -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 @@ -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 From 08904150521ed1df5a9f6a7845d8c10b8d207f08 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:14:35 +0000 Subject: [PATCH 6/6] fix(database): avoid recycling active pooled connections Track whether a pooled database wrapper is available for reuse so time-based recycling only runs while the wrapper is idle or being borrowed from the pool again. This prevents both max_lifetime and max_idle_time checks from replacing the underlying connection while a caller still holds the wrapper, preserving active transaction state for repeated getConnection() calls within one borrow window. Keep invalid and closed connection checks outside the reuse gate so genuinely stale wrappers still reconnect immediately. Leave heartbeat predicates unchanged so idle sweeps can continue discarding expired connections before pinging. Update pooled connection tests to model last-use updates on reuse, prove expired lifetime and idle windows do not reconnect active borrows, and keep existing released-wrapper reuse coverage intact. --- src/database/src/Pool/PooledConnection.php | 26 ++++++---- .../Database/PooledConnectionTest.php | 48 ++++++++++++++++--- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index 8bbfcef7f..3df5ffb5b 100644 --- a/src/database/src/Pool/PooledConnection.php +++ b/src/database/src/Pool/PooledConnection.php @@ -47,6 +47,8 @@ class PooledConnection implements PoolConnectionInterface protected float $createdAt = 0.0; + protected bool $availableForReuse = false; + protected bool $invalid = false; protected ?Dispatcher $dispatcher = null; @@ -80,6 +82,8 @@ public function getConnection(): Connection public function getActiveConnection(): Connection { if ($this->check()) { + $this->availableForReuse = false; + return $this->connection; } @@ -138,6 +142,7 @@ public function reconnect(): bool $now = microtime(true); $this->lastUseTime = $now; $this->createdAt = $now; + $this->availableForReuse = false; $this->markValid(); return true; @@ -158,17 +163,21 @@ public function check(): bool $now = microtime(true); - if ($this->isLifetimeExpired($now)) { - 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; + } - $maxIdleTime = $this->pool->getOption()->getMaxIdleTime(); + $maxIdleTime = $this->pool->getOption()->getMaxIdleTime(); - if ($now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) { - return false; - } + if ($now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) { + return false; + } - $this->lastUseTime = $now; + $this->lastUseTime = $now; + } return true; } @@ -279,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); } } diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index e131198cf..441355193 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -264,17 +264,28 @@ function () use (&$fired) { $this->assertTrue($fired, 'ReleaseConnection event should be dispatched when configured'); } - public function testLastUseTimeUpdatedOnCheck(): void + public function testLastUseTimeUpdatedOnReuseCheck(): void { $pool = new DbPool($this->app, 'pool_test'); - $pooledConnection = $this->createPooledConnection($pool); + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $pooledConnection->getConnection(); $initialTime = $pooledConnection->getLastUseTime(); + $pooledConnection->release(); + usleep(10000); // 10ms - $pooledConnection->check(); - $this->assertGreaterThan($initialTime, $pooledConnection->getLastUseTime()); + /** @var PooledConnection $nextPooledConnection */ + $nextPooledConnection = $pool->get(); + $nextPooledConnection->getConnection(); + + $this->assertSame($pooledConnection, $nextPooledConnection); + $this->assertGreaterThan($initialTime, $nextPooledConnection->getLastUseTime()); + + $nextPooledConnection->release(); } public function testInvalidConnectionReconnectsEvenWithFreshReleaseTime(): void @@ -289,7 +300,7 @@ public function testInvalidConnectionReconnectsEvenWithFreshReleaseTime(): void $this->assertNotSame($originalConnection, $pooledConnection->getActiveConnection()); } - public function testExpiredLifetimeReconnectsBeforeReuseEvenWithoutHeartbeat(): void + public function testExpiredLifetimeDoesNotReconnectDuringActiveBorrow(): void { $this->app->make('config')->set('database.connections.pool_test.pool.max_lifetime', 1.0); @@ -299,10 +310,28 @@ public function testExpiredLifetimeReconnectsBeforeReuseEvenWithoutHeartbeat(): $this->assertSame(1.0, $pool->getOption()->getMaxLifetime()); + $originalConnection->beginTransaction(); $this->ageConnectionGeneration($pooledConnection); - $this->assertFalse($pooledConnection->check()); - $this->assertNotSame($originalConnection, $pooledConnection->getActiveConnection()); + $this->assertTrue($pooledConnection->check()); + $this->assertSame($originalConnection, $pooledConnection->getActiveConnection()); + $this->assertSame(1, $originalConnection->transactionLevel()); + + $originalConnection->rollBack(); + } + + public function testExpiredIdleTimeDoesNotReconnectDuringActiveBorrow(): void + { + $this->app->make('config')->set('database.connections.pool_test.pool.max_idle_time', 1.0); + + $pool = new DbPool($this->app, 'pool_test'); + $pooledConnection = $this->createPooledConnection($pool); + $originalConnection = $pooledConnection->getConnection(); + + $this->ageActiveConnectionUse($pooledConnection); + + $this->assertTrue($pooledConnection->check()); + $this->assertSame($originalConnection, $pooledConnection->getActiveConnection()); } public function testExpiredLifetimeReconnectsWhenBorrowedFromPoolAgainWithoutHeartbeat(): void @@ -526,4 +555,9 @@ private function ageConnectionGeneration(PooledConnection $connection): void { (new ReflectionProperty(PooledConnection::class, 'createdAt'))->setValue($connection, microtime(true) - 5.0); } + + private function ageActiveConnectionUse(PooledConnection $connection): void + { + (new ReflectionProperty(PooledConnection::class, 'lastUseTime'))->setValue($connection, microtime(true) - 5.0); + } }