From 2d21d0d9295db74bb92c3b74ef955c64b6807f54 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 03:04:34 +0000
Subject: [PATCH 1/9] fix(pool): honor disabled keepalive heartbeat
Respect PoolOption heartbeat values on KeepaliveConnection by only registering a timer when the configured heartbeat is positive.
Pass the raw heartbeat float to Timer::tick instead of truncating it through an integer helper, which avoids turning sub-second heartbeat intervals into near-busy-loop timers.
Document that KeepaliveConnection max_idle_time eviction is driven by the heartbeat timer, clean up the stale inherited idle-close comment, and remove the completed Boost todo entry.
Add regression coverage for disabled heartbeat creating no timer, for enabled heartbeat still closing idle connections, and update the existing heartbeat test to opt into a positive interval.
Verification: ./vendor/bin/phpunit tests/Pool/HeartbeatConnectionTest.php; composer fix.
---
src/boost/todo.md | 1 -
src/pool/src/KeepaliveConnection.php | 30 ++++------
.../Pool/Fixtures/KeepaliveConnectionStub.php | 4 ++
tests/Pool/HeartbeatConnectionTest.php | 59 ++++++++++++++++---
4 files changed, 67 insertions(+), 27 deletions(-)
diff --git a/src/boost/todo.md b/src/boost/todo.md
index 4a6bf0247..94ef75c14 100644
--- a/src/boost/todo.md
+++ b/src/boost/todo.md
@@ -51,7 +51,6 @@
## 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
diff --git a/src/pool/src/KeepaliveConnection.php b/src/pool/src/KeepaliveConnection.php
index a9e1cc312..e5a7e1f98 100644
--- a/src/pool/src/KeepaliveConnection.php
+++ b/src/pool/src/KeepaliveConnection.php
@@ -161,19 +161,29 @@ public function isTimeout(): bool
}
/**
- * Add a heartbeat timer.
+ * Add a heartbeat timer when heartbeat is enabled.
+ *
+ * For keepalive connections, max_idle_time eviction is driven by this
+ * timer, so disabling heartbeat also disables background idle closing.
*/
protected function addHeartbeat(): void
{
$this->connected = true;
- $this->timerId = $this->timer->tick($this->getHeartbeatSeconds(), function () {
+
+ $heartbeat = $this->pool->getOption()->getHeartbeat();
+
+ if ($heartbeat <= 0) {
+ return;
+ }
+
+ $this->timerId = $this->timer->tick($heartbeat, function () {
try {
if (! $this->isConnected()) {
return;
}
if ($this->isTimeout()) {
- // The socket does not use in double of heartbeat.
+ // Close the socket if it has been idle longer than max_idle_time.
$this->close();
return;
@@ -190,20 +200,6 @@ protected function addHeartbeat(): void
});
}
- /**
- * Get the heartbeat interval in seconds.
- */
- protected function getHeartbeatSeconds(): int
- {
- $heartbeat = $this->pool->getOption()->getHeartbeat();
-
- if ($heartbeat > 0) {
- return intval($heartbeat);
- }
-
- return 10;
- }
-
/**
* Clear the connection state.
*/
diff --git a/tests/Pool/Fixtures/KeepaliveConnectionStub.php b/tests/Pool/Fixtures/KeepaliveConnectionStub.php
index a8323f3aa..9d4b9687e 100644
--- a/tests/Pool/Fixtures/KeepaliveConnectionStub.php
+++ b/tests/Pool/Fixtures/KeepaliveConnectionStub.php
@@ -12,6 +12,8 @@ class KeepaliveConnectionStub extends KeepaliveConnection
{
public Timer $timer;
+ public int $closeCount = 0;
+
protected mixed $activeConnection = null;
public function setActiveConnection(mixed $connection): void
@@ -26,6 +28,8 @@ protected function getActiveConnection(): mixed
protected function sendClose(mixed $connection): void
{
+ ++$this->closeCount;
+
$data = CoroutineContext::get('test.pool.heartbeat_connection', []);
$data['close'] = 'close protocol';
CoroutineContext::set('test.pool.heartbeat_connection', $data);
diff --git a/tests/Pool/HeartbeatConnectionTest.php b/tests/Pool/HeartbeatConnectionTest.php
index 218aabd8f..2e9a52356 100644
--- a/tests/Pool/HeartbeatConnectionTest.php
+++ b/tests/Pool/HeartbeatConnectionTest.php
@@ -7,6 +7,7 @@
use Hypervel\Container\Container;
use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Container\Container as ContainerContract;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Support\ClassInvoker;
use Hypervel\Tests\Pool\Fixtures\HeartbeatPoolStub;
use Hypervel\Tests\Pool\Fixtures\KeepaliveConnectionStub;
@@ -21,7 +22,7 @@ protected function tearDown(): void
parent::tearDown();
}
- public function testConnectionConstruct()
+ public function testConnectionConstruct(): void
{
$container = $this->getContainer();
$pool = $container->make(HeartbeatPoolStub::class);
@@ -43,14 +44,14 @@ public function testConnectionConstruct()
$this->assertSame(2, $pool->getCurrentConnections());
}
- public function testConnectionCall()
+ public function testConnectionCall(): void
{
$container = $this->getContainer();
$pool = $container->make(HeartbeatPoolStub::class);
/** @var KeepaliveConnectionStub $connection */
$connection = $pool->get();
$connection->setActiveConnection($conn = new class {
- public function send(string $data)
+ public function send(string $data): string
{
return str_repeat($data, 2);
}
@@ -63,9 +64,9 @@ public function send(string $data)
$this->assertSame($result, str_repeat($str, 2));
}
- public function testConnectionHeartbeat()
+ public function testConnectionHeartbeat(): void
{
- $container = $this->getContainer();
+ $container = $this->getContainer(['heartbeat' => 0.001]);
$pool = $container->make(HeartbeatPoolStub::class);
/** @var KeepaliveConnectionStub $connection */
$connection = $pool->get();
@@ -79,7 +80,47 @@ public function testConnectionHeartbeat()
$this->assertSame('close protocol', CoroutineContext::get('test.pool.heartbeat_connection')['close']);
}
- public function testConnectionDestruct()
+ public function testDisabledHeartbeatDoesNotStartTimer(): void
+ {
+ $container = $this->getContainer([
+ 'heartbeat' => -1,
+ 'max_idle_time' => 0.001,
+ ]);
+ $pool = $container->make(HeartbeatPoolStub::class);
+ /** @var KeepaliveConnectionStub $connection */
+ $connection = $pool->get();
+ $connection->reconnect();
+ $timer = $connection->timer;
+
+ $this->assertTrue($connection->check());
+ $this->assertSame(0, count((new ClassInvoker($timer))->closures));
+
+ Coroutine::sleep(0.01);
+
+ $this->assertTrue($connection->check());
+ $this->assertSame(0, $connection->closeCount);
+
+ $connection->close();
+ }
+
+ public function testEnabledHeartbeatClosesIdleConnection(): void
+ {
+ $container = $this->getContainer([
+ 'heartbeat' => 0.001,
+ 'max_idle_time' => 0.001,
+ ]);
+ $pool = $container->make(HeartbeatPoolStub::class);
+ /** @var KeepaliveConnectionStub $connection */
+ $connection = $pool->get();
+ $connection->reconnect();
+
+ Coroutine::sleep(0.01);
+
+ $this->assertFalse($connection->check());
+ $this->assertSame(1, $connection->closeCount);
+ }
+
+ public function testConnectionDestruct(): void
{
$container = $this->getContainer();
$pool = $container->make(HeartbeatPoolStub::class);
@@ -97,13 +138,13 @@ public function testConnectionDestruct()
$this->assertSame('close protocol', CoroutineContext::get('test.pool.heartbeat_connection')['close']);
}
- protected function getContainer()
+ protected function getContainer(array $poolConfig = []): ContainerContract
{
$container = m::mock(ContainerContract::class);
Container::setInstance($container);
- $container->shouldReceive('make')->with(HeartbeatPoolStub::class)->andReturnUsing(function () use ($container) {
- return new HeartbeatPoolStub($container, 'test', []);
+ $container->shouldReceive('make')->with(HeartbeatPoolStub::class)->andReturnUsing(function () use ($container, $poolConfig) {
+ return new HeartbeatPoolStub($container, 'test', $poolConfig);
});
return $container;
From 6b0343f989d28938d7397223c1bf374ba8f0dfbc Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 04:19:00 +0000
Subject: [PATCH 2/9] feat(redis): track pooled connection lifetimes
Add Redis connection generation timestamps and reuse-state tracking so pooled wrappers can distinguish active borrows from idle reusable connections.
Centralize reconnect bookkeeping through markReconnected and make release failures return an invalid wrapper to the pool, preventing pool-slot leaks while forcing a clean reconnect on the next borrow.
---
src/redis/src/PhpRedisClusterConnection.php | 3 +-
src/redis/src/PhpRedisConnection.php | 3 +-
src/redis/src/RedisConnection.php | 163 +++++++++++++++++++-
3 files changed, 163 insertions(+), 6 deletions(-)
diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php
index d28f61f1a..0bdc7cf9e 100644
--- a/src/redis/src/PhpRedisClusterConnection.php
+++ b/src/redis/src/PhpRedisClusterConnection.php
@@ -37,8 +37,7 @@ public function reconnect(): bool
// RedisCluster doesn't support select(), no database selection.
$this->connection = $redis;
- $this->markValid();
- $this->lastUseTime = microtime(true);
+ $this->markReconnected();
if (($this->config['event']['enable'] ?? false) && $this->container->bound('events')) {
$this->eventDispatcher = $this->container->make('events');
diff --git a/src/redis/src/PhpRedisConnection.php b/src/redis/src/PhpRedisConnection.php
index b64afc57a..bebc8608a 100644
--- a/src/redis/src/PhpRedisConnection.php
+++ b/src/redis/src/PhpRedisConnection.php
@@ -59,8 +59,7 @@ public function reconnect(): bool
}
$this->connection = $redis;
- $this->markValid();
- $this->lastUseTime = microtime(true);
+ $this->markReconnected();
if (($this->config['event']['enable'] ?? false) && $this->container->bound('events')) {
$this->eventDispatcher = $this->container->make('events');
diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php
index ccb238ad2..b228fd693 100644
--- a/src/redis/src/RedisConnection.php
+++ b/src/redis/src/RedisConnection.php
@@ -10,6 +10,8 @@
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Log\StdoutLoggerInterface;
use Hypervel\Contracts\Pool\PoolInterface;
+use Hypervel\Engine\Channel;
+use Hypervel\Engine\Coroutine;
use Hypervel\Pool\Connection as BaseConnection;
use Hypervel\Pool\Exceptions\ConnectionException;
use Hypervel\Redis\Exceptions\InvalidRedisOptionException;
@@ -21,8 +23,11 @@
use Redis;
use RedisCluster;
use RedisException;
+use Swoole\Coroutine\CanceledException;
use Throwable;
+use function Hypervel\Coroutine\go;
+
/**
* Abstract base class for pooled Redis connections with Laravel-style method transformations.
*
@@ -336,6 +341,10 @@ abstract class RedisConnection extends BaseConnection
protected Redis|RedisCluster|null $connection = null;
+ protected float $createdAt = 0.0;
+
+ protected bool $availableForReuse = false;
+
protected ?Dispatcher $eventDispatcher = null;
protected array $config = [
@@ -438,6 +447,8 @@ private function executeCommand(string $name, array $arguments): mixed
public function getActiveConnection(): static
{
if ($this->check()) {
+ $this->availableForReuse = false;
+
return $this;
}
@@ -448,6 +459,37 @@ public function getActiveConnection(): static
return $this;
}
+ /**
+ * Check if the connection is still valid.
+ */
+ public function check(): bool
+ {
+ if ($this->invalid) {
+ return false;
+ }
+
+ if ($this->connection === null) {
+ return false;
+ }
+
+ $now = microtime(true);
+
+ if ($this->availableForReuse) {
+ // Mirrors Database\Pool\PooledConnection recycling logic. Keep in sync.
+ if ($this->isLifetimeExpired($now)) {
+ return false;
+ }
+
+ if ($now > $this->pool->getOption()->getMaxIdleTime() + max($this->lastReleaseTime, $this->lastUseTime)) {
+ return false;
+ }
+
+ $this->lastUseTime = $now;
+ }
+
+ return true;
+ }
+
/**
* Get the connection name.
*/
@@ -469,6 +511,18 @@ public function getEventDispatcher(): ?Dispatcher
*/
abstract public function reconnect(): bool;
+ /**
+ * Mark the underlying Redis client as freshly connected.
+ */
+ protected function markReconnected(): void
+ {
+ $now = microtime(true);
+ $this->lastUseTime = $now;
+ $this->createdAt = $now;
+ $this->availableForReuse = false;
+ $this->markValid();
+ }
+
/**
* Set configured options on a Redis or RedisCluster client.
*/
@@ -581,6 +635,39 @@ public function close(): bool
return true;
}
+ /**
+ * Check the Redis client for heartbeat health.
+ */
+ public function heartbeatCheck(float $timeout): bool
+ {
+ if ($this->invalid || ! ($this->connection instanceof Redis || $this->connection instanceof RedisCluster)) {
+ return false;
+ }
+
+ $result = new Channel(1);
+
+ $started = go(function () use ($result) {
+ try {
+ $result->push($this->pingForHeartbeat(), 0.0);
+ } catch (CanceledException) {
+ }
+ });
+
+ if ($started === false) {
+ return false;
+ }
+
+ if ($result->pop($timeout) !== true) {
+ Coroutine::cancelById($started, throwException: true);
+
+ return false;
+ }
+
+ $this->lastUseTime = microtime(true);
+
+ return true;
+ }
+
/**
* Release the connection back to pool.
*/
@@ -594,10 +681,12 @@ public function release(): void
$this->select($defaultDb);
$this->database = null;
}
-
- parent::release();
} catch (Throwable $exception) {
$this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL);
+ $this->markInvalid();
+ } finally {
+ $this->availableForReuse = true;
+ parent::release();
}
}
@@ -609,6 +698,40 @@ public function setDatabase(?int $database): void
$this->database = $database;
}
+ /**
+ * Determine if this connection has been idle long enough to be evicted.
+ */
+ public function isIdleExpired(?float $now = null): bool
+ {
+ if ($this->lastReleaseTime === 0.0) {
+ return false;
+ }
+
+ return ($now ?? microtime(true)) > $this->pool->getOption()->getMaxIdleTime() + $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;
+ }
+
/**
* Retry a redis command after reconnecting.
*
@@ -647,6 +770,42 @@ public function isCluster(): bool
return false;
}
+ /**
+ * Ping Redis for heartbeat health without shadowing the public Redis PING command.
+ */
+ protected function pingForHeartbeat(): bool
+ {
+ try {
+ if ($this->connection instanceof Redis) {
+ return $this->connection->ping() !== false;
+ }
+
+ if ($this->connection instanceof RedisCluster) {
+ $masters = $this->connection->_masters();
+
+ if ($masters === []) {
+ return false;
+ }
+
+ foreach ($masters as $master) {
+ if ($this->connection->ping($master) === false) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ } catch (Throwable $exception) {
+ if ($exception instanceof CanceledException) {
+ throw $exception;
+ }
+
+ return false;
+ }
+ }
+
/**
* Log a redis connection message.
*/
From cf5c7ff06479cdb7d5f053668b89c4b249d41321 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 04:19:06 +0000
Subject: [PATCH 3/9] feat(redis): add pool heartbeat sweeps
Start an optional Redis pool heartbeat timer when configured and sweep only idle connections in the pool channel.
Recycle expired idle generations, discard failed heartbeat checks, guard against late heartbeat completions after flushAll, and keep active borrowed connections untouched.
---
src/redis/src/Pool/RedisPool.php | 164 +++++++++++++++++++++++++++++++
1 file changed, 164 insertions(+)
diff --git a/src/redis/src/Pool/RedisPool.php b/src/redis/src/Pool/RedisPool.php
index 0de453cd8..bbceccc84 100644
--- a/src/redis/src/Pool/RedisPool.php
+++ b/src/redis/src/Pool/RedisPool.php
@@ -5,18 +5,29 @@
namespace Hypervel\Redis\Pool;
use Hypervel\Contracts\Container\Container;
+use Hypervel\Contracts\Log\StdoutLoggerInterface;
use Hypervel\Contracts\Pool\ConnectionInterface;
+use Hypervel\Coordinator\Timer;
use Hypervel\Pool\Pool;
use Hypervel\Redis\Frequency;
use Hypervel\Redis\PhpRedisClusterConnection;
use Hypervel\Redis\PhpRedisConnection;
use Hypervel\Redis\RedisConfig;
+use Hypervel\Redis\RedisConnection;
use Hypervel\Support\Arr;
+use Psr\Log\LoggerInterface;
+use Throwable;
class RedisPool extends Pool
{
protected array $config;
+ protected ?Timer $heartbeatTimer = null;
+
+ protected ?int $heartbeatTimerId = null;
+
+ protected int $heartbeatGeneration = 0;
+
/**
* Create a new Redis pool instance.
*/
@@ -29,6 +40,17 @@ public function __construct(Container $container, string $name)
$this->frequency = new Frequency($this);
parent::__construct($container, $name, $poolOptions);
+
+ $this->heartbeatTimer = new Timer($this->getLogger());
+ $this->startHeartbeat();
+ }
+
+ /**
+ * Destroy the Redis pool.
+ */
+ public function __destruct()
+ {
+ $this->clearHeartbeat();
}
/**
@@ -50,4 +72,146 @@ protected function createConnection(): ConnectionInterface
return new PhpRedisConnection($this->container, $this, $this->config);
}
+
+ /**
+ * Flush all connections from the pool.
+ */
+ public function flushAll(): void
+ {
+ $this->clearHeartbeat();
+
+ parent::flushAll();
+ }
+
+ /**
+ * Start the heartbeat timer if configured.
+ */
+ protected function startHeartbeat(): void
+ {
+ if ($this->heartbeatTimer === null || $this->option->getHeartbeat() <= 0) {
+ return;
+ }
+
+ $this->heartbeatTimerId = $this->heartbeatTimer->tick(
+ $this->option->getHeartbeat(),
+ function (bool $isClosing): ?string {
+ if ($isClosing) {
+ return Timer::STOP;
+ }
+
+ $this->heartbeat();
+
+ return null;
+ }
+ );
+ }
+
+ /**
+ * 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 RedisConnection) {
+ break;
+ }
+
+ $this->heartbeatConnection($connection);
+ }
+ }
+
+ /**
+ * Heartbeat one idle connection.
+ */
+ protected function heartbeatConnection(RedisConnection $connection): void
+ {
+ try {
+ $now = microtime(true);
+
+ if ($connection->isLifetimeExpired($now)) {
+ $this->discardHeartbeatConnection($connection);
+
+ return;
+ }
+
+ if ($connection->isIdleExpired($now) && $this->currentConnections > $this->option->getMinConnections()) {
+ $this->discardHeartbeatConnection($connection);
+
+ return;
+ }
+
+ $heartbeatGeneration = $this->heartbeatGeneration;
+
+ if ($connection->heartbeatCheck($this->option->getHeartbeatTimeout())) {
+ if ($heartbeatGeneration === $this->heartbeatGeneration) {
+ $this->release($connection);
+ } else {
+ $this->discardHeartbeatConnection($connection);
+ }
+
+ return;
+ }
+
+ $this->discardHeartbeatConnection($connection);
+ } catch (Throwable $exception) {
+ $this->logHeartbeatError('Redis heartbeat failed: ' . $exception);
+ $this->discardHeartbeatConnection($connection);
+ }
+ }
+
+ /**
+ * Discard an idle connection from the pool.
+ */
+ protected function discardHeartbeatConnection(RedisConnection $connection): void
+ {
+ --$this->currentConnections;
+
+ try {
+ $connection->close();
+ } catch (Throwable $exception) {
+ $this->logHeartbeatError('Redis 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) {
+ }
+ }
+
+ /**
+ * Get the logger instance if available.
+ */
+ private function getLogger(): ?LoggerInterface
+ {
+ if (! $this->container->has(StdoutLoggerInterface::class)) {
+ return null;
+ }
+
+ return $this->container->make(StdoutLoggerInterface::class);
+ }
}
From 20254f349bd474f299a92740d01a9122e077b16b Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 04:19:12 +0000
Subject: [PATCH 4/9] fix(redis): validate held pooled connections
Validate pooled Redis wrappers before withConnection and withPinnedConnection callbacks run so expired or invalid idle generations reconnect before user code receives them.
Keep Redis connection-only lifecycle helpers out of the proxy and facade command surfaces.
---
src/redis/src/RedisProxy.php | 9 +++++++++
src/support/src/Facades/Redis.php | 5 +++++
2 files changed, 14 insertions(+)
diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php
index 62c29e71f..24ee11c95 100644
--- a/src/redis/src/RedisProxy.php
+++ b/src/redis/src/RedisProxy.php
@@ -53,9 +53,14 @@ class RedisProxy implements ConnectionContract
'connect',
'getActiveConnection',
'getConnection',
+ 'getCreatedAt',
'getLastReleaseTime',
'getLastUseTime',
'getShouldTransform',
+ 'heartbeatCheck',
+ 'isIdleExpired',
+ 'isLifetimeExpired',
+ 'masters',
'pconnect',
'reconnect',
'release',
@@ -313,6 +318,8 @@ public function withConnection(callable $callback, bool $transform = true): mixe
$connection = $this->getConnection($hasContextConnection, $transform);
try {
+ $connection->getConnection();
+
return $callback($connection);
} finally {
if (! $hasContextConnection) {
@@ -339,6 +346,8 @@ public function withPinnedConnection(callable $callback): mixed
}
try {
+ $connection->getConnection();
+
return $callback();
} finally {
if (! $hadContextConnection) {
diff --git a/src/support/src/Facades/Redis.php b/src/support/src/Facades/Redis.php
index ed126c4d4..b29fffa0e 100644
--- a/src/support/src/Facades/Redis.php
+++ b/src/support/src/Facades/Redis.php
@@ -320,9 +320,14 @@ protected static function ignoredFacadeDocumenterMethods(): array
'connect',
'getActiveConnection',
'getConnection',
+ 'getCreatedAt',
'getLastReleaseTime',
'getLastUseTime',
'getShouldTransform',
+ 'heartbeatCheck',
+ 'isIdleExpired',
+ 'isLifetimeExpired',
+ 'masters',
'pconnect',
'reconnect',
'release',
From a2da0a91ff4e302dea2f51d6baee97bc5ee259ea Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 04:19:17 +0000
Subject: [PATCH 5/9] docs(redis): document pool heartbeat options
Expose optional Redis pool heartbeat_timeout and max_lifetime settings in the source database config while keeping heartbeat and lifetime recycling disabled by default.
Update the Redis docs to describe checkout recycling, optional background heartbeat sweeps, heartbeat timeouts, idle recycling, and generation lifetime behavior.
---
src/boost/docs/redis.md | 14 +++++++++++---
src/foundation/config/database.php | 20 +++++++++++++++-----
2 files changed, 26 insertions(+), 8 deletions(-)
diff --git a/src/boost/docs/redis.md b/src/boost/docs/redis.md
index fcbf24eba..66afb56af 100644
--- a/src/boost/docs/redis.md
+++ b/src/boost/docs/redis.md
@@ -57,8 +57,10 @@ You may configure your application's Redis settings via the `config/database.php
'max_connections' => (int) env('REDIS_MAX_CONNECTIONS', 10),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
- 'heartbeat' => -1,
+ 'heartbeat' => (float) env('REDIS_HEARTBEAT', -1),
+ 'heartbeat_timeout' => (float) env('REDIS_HEARTBEAT_TIMEOUT', 1.0),
'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60),
+ 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1),
],
],
@@ -78,8 +80,10 @@ You may configure your application's Redis settings via the `config/database.php
'max_connections' => (int) env('REDIS_CACHE_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
- 'heartbeat' => -1,
+ 'heartbeat' => (float) env('REDIS_CACHE_HEARTBEAT', env('REDIS_HEARTBEAT', -1)),
+ 'heartbeat_timeout' => (float) env('REDIS_CACHE_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)),
'max_idle_time' => (float) env('REDIS_CACHE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)),
+ 'max_lifetime' => (float) env('REDIS_CACHE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)),
],
],
],
@@ -262,12 +266,16 @@ Hypervel pools Redis connections so commands can reuse established sockets acros
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
+ 'heartbeat_timeout' => 1.0,
'max_idle_time' => 60.0,
+ 'max_lifetime' => -1,
],
],
```
-The `min_connections` and `max_connections` options define the size of the pool. The `wait_timeout` option controls how long a coroutine will wait for a pooled connection to become available, while `max_idle_time` controls how long an idle connection may remain in the pool before it is recycled.
+The `min_connections` and `max_connections` options define the size of the pool. The `connect_timeout` option controls how long Hypervel will wait while opening a new Redis connection. The `wait_timeout` option controls how long a coroutine may wait for a pooled connection to become available. The `heartbeat` option controls how often Hypervel validates idle connections in the worker pool; set this value to `-1` to disable background heartbeats. 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 an idle connection may remain reusable before it is recycled, and 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 `max_lifetime` to `-1` to disable lifetime recycling.
+
+Idle and lifetime recycling are checked when a connection is borrowed from the pool. When heartbeat is enabled, Hypervel also runs a background sweep over idle pooled Redis connections so stale sockets are found before a request needs them. Heartbeat and max lifetime recycling apply to Hypervel's worker pool whether the connection points directly at Redis, a managed Redis service, or a proxy.
## Interacting With Redis
diff --git a/src/foundation/config/database.php b/src/foundation/config/database.php
index c6d573e7c..7f12d92db 100644
--- a/src/foundation/config/database.php
+++ b/src/foundation/config/database.php
@@ -222,8 +222,10 @@
'max_connections' => (int) env('REDIS_MAX_CONNECTIONS', 10),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
- 'heartbeat' => -1,
+ 'heartbeat' => (float) env('REDIS_HEARTBEAT', -1),
+ 'heartbeat_timeout' => (float) env('REDIS_HEARTBEAT_TIMEOUT', 1.0),
'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60),
+ 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1),
],
],
@@ -243,8 +245,10 @@
'max_connections' => (int) env('REDIS_CACHE_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
- 'heartbeat' => -1,
+ 'heartbeat' => (float) env('REDIS_CACHE_HEARTBEAT', env('REDIS_HEARTBEAT', -1)),
+ 'heartbeat_timeout' => (float) env('REDIS_CACHE_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)),
'max_idle_time' => (float) env('REDIS_CACHE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)),
+ 'max_lifetime' => (float) env('REDIS_CACHE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)),
],
],
@@ -264,8 +268,10 @@
'max_connections' => (int) env('REDIS_SESSION_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
- 'heartbeat' => -1,
+ 'heartbeat' => (float) env('REDIS_SESSION_HEARTBEAT', env('REDIS_HEARTBEAT', -1)),
+ 'heartbeat_timeout' => (float) env('REDIS_SESSION_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)),
'max_idle_time' => (float) env('REDIS_SESSION_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)),
+ 'max_lifetime' => (float) env('REDIS_SESSION_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)),
],
],
@@ -285,8 +291,10 @@
'max_connections' => (int) env('REDIS_QUEUE_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
- 'heartbeat' => -1,
+ 'heartbeat' => (float) env('REDIS_QUEUE_HEARTBEAT', env('REDIS_HEARTBEAT', -1)),
+ 'heartbeat_timeout' => (float) env('REDIS_QUEUE_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)),
'max_idle_time' => (float) env('REDIS_QUEUE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)),
+ 'max_lifetime' => (float) env('REDIS_QUEUE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)),
],
],
@@ -306,8 +314,10 @@
'max_connections' => (int) env('REDIS_REVERB_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)),
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
- 'heartbeat' => -1,
+ 'heartbeat' => (float) env('REDIS_REVERB_HEARTBEAT', env('REDIS_HEARTBEAT', -1)),
+ 'heartbeat_timeout' => (float) env('REDIS_REVERB_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)),
'max_idle_time' => (float) env('REDIS_REVERB_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)),
+ 'max_lifetime' => (float) env('REDIS_REVERB_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)),
],
],
],
From c43ac80dcfa2c59e439efe9e8696fc6f9c9bd92e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 04:19:23 +0000
Subject: [PATCH 6/9] test(redis): cover pool heartbeat recycling
Add Redis pool heartbeat coverage for disabled timers, idle and lifetime recycling, active-borrow safety, release reset failures, timeout cancellation, flush generation guards, proxy validation, and cluster master pings.
Add a real Redis integration check for heartbeat pings and update existing Redis pool/proxy tests for the new lifecycle methods.
---
.../RedisPoolHeartbeatIntegrationTest.php | 42 ++
tests/Redis/RedisPoolHeartbeatTest.php | 687 ++++++++++++++++++
tests/Redis/RedisPoolTest.php | 2 +
tests/Redis/RedisProxyTest.php | 5 +
4 files changed, 736 insertions(+)
create mode 100644 tests/Integration/Redis/RedisPoolHeartbeatIntegrationTest.php
create mode 100644 tests/Redis/RedisPoolHeartbeatTest.php
diff --git a/tests/Integration/Redis/RedisPoolHeartbeatIntegrationTest.php b/tests/Integration/Redis/RedisPoolHeartbeatIntegrationTest.php
new file mode 100644
index 000000000..24b200e42
--- /dev/null
+++ b/tests/Integration/Redis/RedisPoolHeartbeatIntegrationTest.php
@@ -0,0 +1,42 @@
+app->make('config')->set("database.redis.{$connectionName}", [
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'password' => env('REDIS_PASSWORD', null) ?: null,
+ 'port' => (int) env('REDIS_PORT', 6379),
+ 'database' => $this->getParallelRedisDb(),
+ 'pool' => [
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'connect_timeout' => 10.0,
+ 'wait_timeout' => 3.0,
+ 'heartbeat' => -1,
+ 'heartbeat_timeout' => 1.0,
+ 'max_idle_time' => 60.0,
+ 'max_lifetime' => -1.0,
+ ],
+ 'options' => ['prefix' => ''],
+ ]);
+
+ Redis::connection($connectionName)->withConnection(function (RedisConnection $connection) {
+ $this->assertTrue($connection->heartbeatCheck(1.0));
+ });
+ }
+}
diff --git a/tests/Redis/RedisPoolHeartbeatTest.php b/tests/Redis/RedisPoolHeartbeatTest.php
new file mode 100644
index 000000000..0915d32cf
--- /dev/null
+++ b/tests/Redis/RedisPoolHeartbeatTest.php
@@ -0,0 +1,687 @@
+pools as $pool) {
+ run(fn () => $pool->flushAll());
+ }
+
+ parent::tearDown();
+ }
+
+ public function testDisabledHeartbeatDoesNotStartTimer(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'heartbeat' => -1,
+ ]);
+
+ $this->assertSame(0, $pool->heartbeatTimerClosureCount());
+ });
+ }
+
+ public function testEnabledHeartbeatStartsTimerAndFlushAllClearsIt(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'heartbeat' => 0.001,
+ ]);
+
+ $this->assertSame(1, $pool->heartbeatTimerClosureCount());
+
+ $pool->flushAll();
+
+ $this->assertSame(0, $pool->heartbeatTimerClosureCount());
+ });
+ }
+
+ public function testHeartbeatKeepsMinimumConnectionsWarmAndEvictsExpiredExtras(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 3,
+ 'heartbeat' => -1,
+ 'max_idle_time' => 1.0,
+ ]);
+
+ $connections = [
+ $pool->get(),
+ $pool->get(),
+ $pool->get(),
+ ];
+
+ foreach ($connections as $connection) {
+ $connection->release();
+ $this->ageReleasedConnection($connection);
+ }
+
+ $pool->runHeartbeatForTest();
+
+ $this->assertSame(1, $pool->getCurrentConnections());
+ $this->assertSame(1, $pool->getConnectionsInChannel());
+ });
+ }
+
+ public function testHeartbeatDiscardsLifetimeExpiredIdleConnectionBeforeCheckingHeartbeat(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ 'max_lifetime' => 1.0,
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection);
+
+ $connection->release();
+ $this->ageConnectionGeneration($connection);
+
+ $pool->runHeartbeatForTest();
+
+ $this->assertSame(0, $connection->heartbeatChecks);
+ $this->assertSame(0, $pool->getCurrentConnections());
+ $this->assertSame(0, $pool->getConnectionsInChannel());
+ });
+ }
+
+ public function testHeartbeatDoesNotRecycleBorrowedLifetimeExpiredConnection(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ 'max_lifetime' => 1.0,
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection);
+
+ $client = $connection->nativeClientForTest();
+ $this->ageConnectionGeneration($connection);
+
+ $pool->runHeartbeatForTest();
+
+ $connection->getConnection();
+
+ $this->assertSame($client, $connection->nativeClientForTest());
+ $this->assertSame(1, $pool->getCurrentConnections());
+ $this->assertSame(0, $pool->getConnectionsInChannel());
+
+ $connection->release();
+ });
+ }
+
+ public function testMaxLifetimeDisabledDoesNotRecycleAgedConnectionGeneration(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ 'max_lifetime' => -1.0,
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection);
+ $client = $connection->nativeClientForTest();
+
+ $connection->release();
+ $this->ageConnectionGeneration($connection);
+
+ $nextConnection = $pool->get();
+ $nextConnection->getConnection();
+
+ $this->assertSame($connection, $nextConnection);
+ $this->assertSame($client, $connection->nativeClientForTest());
+ $this->assertSame(1, $connection->reconnectCount);
+
+ $nextConnection->release();
+ });
+ }
+
+ public function testHeartbeatRefreshedIdleConnectionIsReused(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ 'max_idle_time' => 1.0,
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection);
+ $client = $connection->nativeClientForTest();
+
+ $connection->release();
+ $this->ageReleaseTimeButKeepLastUseFresh($connection);
+
+ $nextConnection = $pool->get();
+ $nextConnection->getConnection();
+
+ $this->assertSame($connection, $nextConnection);
+ $this->assertSame($client, $connection->nativeClientForTest());
+ $this->assertSame(1, $connection->reconnectCount);
+
+ $nextConnection->release();
+ });
+ }
+
+ public function testLifetimeExpiredConnectionReconnectsBeforeReuse(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ 'max_lifetime' => 1.0,
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection);
+ $client = $connection->nativeClientForTest();
+
+ $connection->release();
+ $this->ageConnectionGeneration($connection);
+
+ $nextConnection = $pool->get();
+ $nextConnection->getConnection();
+
+ $this->assertSame($connection, $nextConnection);
+ $this->assertNotSame($client, $connection->nativeClientForTest());
+ $this->assertSame(2, $connection->reconnectCount);
+
+ $nextConnection->release();
+ });
+ }
+
+ public function testFailedHeartbeatCheckDiscardsConnectionBelowMinimum(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ ], FailingHeartbeatRedisPool::class);
+
+ $connection = $pool->get();
+ $connection->release();
+
+ $pool->runHeartbeatForTest();
+
+ $this->assertSame(0, $pool->getCurrentConnections());
+ $this->assertSame(0, $pool->getConnectionsInChannel());
+ });
+ }
+
+ public function testHeartbeatTimeoutDiscardsWithoutRequeueingLateCompletion(): void
+ {
+ run(function () {
+ SlowHeartbeatRedisConnection::$coroutineId = null;
+
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ 'heartbeat_timeout' => 0.001,
+ ], SlowHeartbeatRedisPool::class);
+
+ $connection = $pool->get();
+ $connection->release();
+
+ $startedAt = microtime(true);
+ $pool->runHeartbeatForTest();
+ $elapsed = microtime(true) - $startedAt;
+
+ $this->assertLessThan(0.2, $elapsed);
+ $this->assertSame(0, $pool->getCurrentConnections());
+ $this->assertSame(0, $pool->getConnectionsInChannel());
+ $this->assertIsInt(SlowHeartbeatRedisConnection::$coroutineId);
+
+ $deadline = microtime(true) + 0.1;
+ while (Coroutine::exists(SlowHeartbeatRedisConnection::$coroutineId) && microtime(true) < $deadline) {
+ usleep(1000);
+ }
+
+ $this->assertFalse(Coroutine::exists(SlowHeartbeatRedisConnection::$coroutineId));
+
+ usleep(100000);
+
+ $this->assertSame(0, $pool->getConnectionsInChannel());
+ });
+ }
+
+ public function testSuccessfulHeartbeatCheckAfterFlushDiscardsConnection(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ ], FlushingHeartbeatRedisPool::class);
+
+ $connection = $pool->get();
+ $connection->release();
+
+ $pool->runHeartbeatForTest();
+
+ $this->assertSame(0, $pool->getCurrentConnections());
+ $this->assertSame(0, $pool->getConnectionsInChannel());
+ });
+ }
+
+ public function testReleaseResetFailureReturnsInvalidConnectionToPool(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection);
+
+ $redis = m::mock(Redis::class);
+ $redis->shouldReceive('select')->once()->with(0)->andThrow(new RuntimeException('select failed'));
+ $connection->setNativeClientForTest($redis);
+ $connection->setDatabase(2);
+
+ $connection->release();
+
+ $this->assertSame(1, $pool->getCurrentConnections());
+ $this->assertSame(1, $pool->getConnectionsInChannel());
+
+ $nextConnection = $pool->get();
+ $nextConnection->getConnection();
+
+ $this->assertSame($connection, $nextConnection);
+ $this->assertSame(2, $connection->reconnectCount);
+
+ $nextConnection->release();
+ });
+ }
+
+ public function testWithConnectionReconnectsExpiredGenerationBeforeCallback(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ 'max_lifetime' => 1.0,
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection);
+ $client = $connection->nativeClientForTest();
+ $connection->release();
+ $this->ageConnectionGeneration($connection);
+
+ $redis = $this->createProxy($pool);
+
+ $redis->withConnection(function (RedisConnection $heldConnection) use ($connection, $client) {
+ $this->assertSame($connection, $heldConnection);
+ $this->assertNotSame($client, $connection->nativeClientForTest());
+ $this->assertSame(2, $connection->reconnectCount);
+ });
+ });
+ }
+
+ public function testPinnedConnectionDoesNotRecycleExpiredGenerationMidBorrow(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ 'max_lifetime' => 1.0,
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection);
+ $connection->release();
+ $this->ageConnectionGeneration($connection);
+
+ $redis = $this->createProxy($pool);
+
+ $redis->withPinnedConnection(function () use ($redis, $connection) {
+ $contextConnection = CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'heartbeat_test');
+
+ $this->assertSame($connection, $contextConnection);
+ $this->assertSame(2, $connection->reconnectCount);
+
+ $client = $connection->nativeClientForTest();
+ $this->ageConnectionGeneration($connection);
+
+ $redis->get('first');
+ $redis->get('second');
+
+ $this->assertSame(2, $connection->reconnectCount);
+ $this->assertSame($client, $connection->nativeClientForTest());
+ });
+ });
+ }
+
+ public function testClusterHeartbeatChecksAllMasters(): void
+ {
+ run(function () {
+ $pool = $this->createPool([
+ 'min_connections' => 1,
+ 'max_connections' => 1,
+ 'heartbeat' => -1,
+ ], ClusterHeartbeatRedisPool::class, [
+ 'cluster' => [
+ 'enable' => true,
+ 'seeds' => ['127.0.0.1:6379'],
+ ],
+ ]);
+
+ $connection = $pool->get();
+ $this->assertInstanceOf(ClusterHeartbeatRedisConnection::class, $connection);
+ $connection->release();
+
+ $pool->runHeartbeatForTest();
+
+ $this->assertSame([
+ ['127.0.0.1', 6379],
+ ['127.0.0.2', 6379],
+ ], $connection->clusterClient->pingedMasters);
+ $this->assertSame(1, $pool->getCurrentConnections());
+ $this->assertSame(1, $pool->getConnectionsInChannel());
+ });
+ }
+
+ /**
+ * @param array $poolOptions
+ * @param array $config
+ */
+ protected function createPool(array $poolOptions = [], string $poolClass = InspectableRedisPool::class, array $config = []): InspectableRedisPool
+ {
+ $connectionConfig = array_replace_recursive([
+ 'host' => '127.0.0.1',
+ 'port' => 6379,
+ 'database' => 0,
+ 'cluster' => ['enable' => false],
+ 'pool' => [
+ 'min_connections' => 1,
+ 'max_connections' => 2,
+ 'connect_timeout' => 10.0,
+ 'wait_timeout' => 3.0,
+ 'heartbeat' => -1,
+ 'heartbeat_timeout' => 1.0,
+ 'max_idle_time' => 60.0,
+ 'max_lifetime' => -1.0,
+ ...$poolOptions,
+ ],
+ ], $config);
+
+ $container = new Container;
+ $redisConfig = m::mock(RedisConfig::class);
+ $redisConfig->shouldReceive('connectionConfig')->once()->with('heartbeat_test')->andReturn($connectionConfig);
+ $container->instance(RedisConfig::class, $redisConfig);
+
+ $pool = new $poolClass($container, 'heartbeat_test');
+ $this->pools[] = $pool;
+
+ return $pool;
+ }
+
+ protected function createProxy(RedisPool $pool): RedisProxy
+ {
+ $poolFactory = m::mock(PoolFactory::class);
+ $poolFactory->shouldReceive('getPool')->with('heartbeat_test')->andReturn($pool);
+
+ return new RedisProxy($poolFactory, 'heartbeat_test');
+ }
+
+ protected function ageReleasedConnection(RedisConnection $connection): void
+ {
+ (new ReflectionProperty(BaseConnection::class, 'lastReleaseTime'))->setValue($connection, microtime(true) - 5.0);
+ (new ReflectionProperty(BaseConnection::class, 'lastUseTime'))->setValue($connection, microtime(true) - 5.0);
+ }
+
+ protected function ageConnectionGeneration(RedisConnection $connection): void
+ {
+ (new ReflectionProperty(RedisConnection::class, 'createdAt'))->setValue($connection, microtime(true) - 5.0);
+ }
+
+ protected function ageReleaseTimeButKeepLastUseFresh(RedisConnection $connection): void
+ {
+ (new ReflectionProperty(BaseConnection::class, 'lastReleaseTime'))->setValue($connection, microtime(true) - 5.0);
+ (new ReflectionProperty(BaseConnection::class, 'lastUseTime'))->setValue($connection, microtime(true));
+ }
+}
+
+class InspectableRedisPool extends RedisPool
+{
+ public function runHeartbeatForTest(): void
+ {
+ $this->heartbeat();
+ }
+
+ public function heartbeatTimerClosureCount(): int
+ {
+ $timer = (new ReflectionProperty(RedisPool::class, 'heartbeatTimer'))->getValue($this);
+
+ return $timer === null ? 0 : count((new ClassInvoker($timer))->closures);
+ }
+
+ protected function createConnection(): ConnectionInterface
+ {
+ return new HeartbeatRedisConnection($this->container, $this, $this->config);
+ }
+}
+
+class HeartbeatRedisConnection extends RedisConnection
+{
+ public int $reconnectCount = 0;
+
+ public int $heartbeatChecks = 0;
+
+ public bool $heartbeatResult = true;
+
+ public bool $useNativeHeartbeat = false;
+
+ public function __construct(Container $container, PoolInterface $pool, array $config)
+ {
+ parent::__construct($container, $pool, $config);
+
+ $this->reconnect();
+ }
+
+ public function reconnect(): bool
+ {
+ $this->connection = m::mock(Redis::class)->shouldIgnoreMissing();
+ ++$this->reconnectCount;
+ $this->markReconnected();
+
+ return true;
+ }
+
+ public function setNativeClientForTest(Redis $redis): void
+ {
+ $this->connection = $redis;
+ }
+
+ public function nativeClientForTest(): Redis
+ {
+ $this->getConnection();
+
+ $this->assertNativeClientForTest();
+
+ return $this->connection;
+ }
+
+ protected function pingForHeartbeat(): bool
+ {
+ if ($this->useNativeHeartbeat) {
+ return parent::pingForHeartbeat();
+ }
+
+ ++$this->heartbeatChecks;
+
+ return $this->heartbeatResult;
+ }
+
+ private function assertNativeClientForTest(): void
+ {
+ if (! $this->connection instanceof Redis) {
+ throw new RuntimeException('Expected native Redis client.');
+ }
+ }
+}
+
+class FailingHeartbeatRedisPool extends InspectableRedisPool
+{
+ protected function createConnection(): ConnectionInterface
+ {
+ $connection = new HeartbeatRedisConnection($this->container, $this, $this->config);
+ $connection->heartbeatResult = false;
+
+ return $connection;
+ }
+}
+
+class SlowHeartbeatRedisPool extends InspectableRedisPool
+{
+ protected function createConnection(): ConnectionInterface
+ {
+ return new SlowHeartbeatRedisConnection($this->container, $this, $this->config);
+ }
+}
+
+class SlowHeartbeatRedisConnection extends HeartbeatRedisConnection
+{
+ public static ?int $coroutineId = null;
+
+ protected function pingForHeartbeat(): bool
+ {
+ self::$coroutineId = Coroutine::id();
+
+ usleep(500000);
+
+ return true;
+ }
+}
+
+class FlushingHeartbeatRedisPool extends InspectableRedisPool
+{
+ protected function createConnection(): ConnectionInterface
+ {
+ return new FlushingHeartbeatRedisConnection($this->container, $this, $this->config);
+ }
+}
+
+class FlushingHeartbeatRedisConnection extends HeartbeatRedisConnection
+{
+ protected function pingForHeartbeat(): bool
+ {
+ $this->pool->flushAll();
+
+ return true;
+ }
+}
+
+class ClusterHeartbeatRedisPool extends InspectableRedisPool
+{
+ protected function createConnection(): ConnectionInterface
+ {
+ return new ClusterHeartbeatRedisConnection($this->container, $this, $this->config);
+ }
+}
+
+class ClusterHeartbeatRedisConnection extends HeartbeatRedisConnection
+{
+ public TestHeartbeatRedisClusterClient $clusterClient;
+
+ public function reconnect(): bool
+ {
+ $redis = new TestHeartbeatRedisClusterClient([
+ ['127.0.0.1', 6379],
+ ['127.0.0.2', 6379],
+ ]);
+ $this->clusterClient = $redis;
+ $this->connection = $redis;
+ $this->useNativeHeartbeat = true;
+ ++$this->reconnectCount;
+ $this->markReconnected();
+
+ return true;
+ }
+}
+
+class TestHeartbeatRedisClusterClient extends RedisCluster
+{
+ /**
+ * @var array
+ */
+ public array $pingedMasters = [];
+
+ /**
+ * @param array $masters
+ */
+ public function __construct(private array $masters)
+ {
+ }
+
+ /**
+ * @return array
+ */
+ public function _masters(): array
+ {
+ return $this->masters;
+ }
+
+ public function ping(array|string $key_or_address, ?string $message = null): mixed
+ {
+ if (is_array($key_or_address)) {
+ $this->pingedMasters[] = $key_or_address;
+ }
+
+ return true;
+ }
+
+ public function close(): bool
+ {
+ return true;
+ }
+}
diff --git a/tests/Redis/RedisPoolTest.php b/tests/Redis/RedisPoolTest.php
index d53521341..d692a7b03 100644
--- a/tests/Redis/RedisPoolTest.php
+++ b/tests/Redis/RedisPoolTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\Redis;
use Hypervel\Contracts\Container\Container;
+use Hypervel\Contracts\Log\StdoutLoggerInterface;
use Hypervel\Contracts\Pool\ConnectionInterface;
use Hypervel\Contracts\Pool\FrequencyInterface;
use Hypervel\Pool\Connection;
@@ -96,6 +97,7 @@ private function mockContainerWithRedisConfig(array $connectionConfig): m\MockIn
$container = m::mock(Container::class);
$container->shouldReceive('make')->with(RedisConfig::class)->once()->andReturn($redisConfig);
+ $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(false);
return $container;
}
diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php
index c10b06621..75cb0a786 100644
--- a/tests/Redis/RedisProxyTest.php
+++ b/tests/Redis/RedisProxyTest.php
@@ -77,9 +77,14 @@ public function testConnectionBoundMethodsCannotBeCalledThroughProxy(): void
'connect',
'getActiveConnection',
'getConnection',
+ 'getCreatedAt',
'getLastReleaseTime',
'getLastUseTime',
'getShouldTransform',
+ 'heartbeatCheck',
+ 'isIdleExpired',
+ 'isLifetimeExpired',
+ 'masters',
'reconnect',
'release',
'safeScan',
From 148d0c0b3dccfe83cfbb3b8c329db901e9fe26a8 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 04:19:29 +0000
Subject: [PATCH 7/9] docs(boost): remove Redis pool heartbeat todo
Remove the Redis pool heartbeat and max-lifetime item now that the feature, docs, config, and regression coverage have been added.
---
src/boost/todo.md | 4 ----
1 file changed, 4 deletions(-)
diff --git a/src/boost/todo.md b/src/boost/todo.md
index 94ef75c14..fae12764f 100644
--- a/src/boost/todo.md
+++ b/src/boost/todo.md
@@ -49,10 +49,6 @@
- Port a `workbench:install` command for Hypervel Testbench. Hypervel has Workbench runtime support, but no scaffolding command for package authors to create the recommended `workbench/` directory and `testbench.yaml`. Correct fix: add an install command adapted to Hypervel's supported Workbench keys (`install`, `auth`, `health`, `sync`, and `discovers`), generate a sensible package-local Workbench skeleton, register the command through Testbench's command loader, and add command coverage.
- Investigate adding Spatie-style role and permission lookup helpers to the permission package. The package is based on `spatie/laravel-permission`, but currently lacks helpers such as `Role::findByName()`, `Role::findById()`, `Role::findOrCreate()`, `Permission::findByName()`, `Permission::findById()`, and `Permission::findOrCreate()`. Check Spatie's current implementation and decide whether these helpers should be ported for API parity, adapted for Hypervel's guard and cache behavior, or intentionally omitted.
-## Pool
-
-- 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
- Make `URL::defaults()` coroutine-safe. The URL generation docs show setting request-wide URL defaults from middleware, but `Hypervel\Routing\UrlGenerator::defaults()` mutates `Hypervel\Routing\RouteUrlGenerator::$defaultParameters` on the worker singleton. In Swoole workers, one request's defaults can leak or race into concurrent and later requests. Correct fix: store request-level named parameter defaults in `CoroutineContext`, preserve any intentional boot-time defaults, keep `getDefaultParameters()` reading the effective defaults, and add coroutine-isolation coverage.
From 9f31e3032a7fb79a22518101af1ef5a2c7d061b5 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 04:49:04 +0000
Subject: [PATCH 8/9] fix(redis): address pool heartbeat review feedback
Reset the selected Redis database marker on every release path so a failed reset cannot leak a stale selected DB into the next reconnect.
Add regression coverage for the marker reset, remove an unused heartbeat test variable, document the non-obvious request-idle heartbeat semantics, and broaden the max_lifetime jitter todo to cover Redis pools as well as database pools.
---
src/boost/todo.md | 2 +-
src/redis/src/RedisConnection.php | 2 ++
tests/Pool/HeartbeatConnectionTest.php | 2 +-
tests/Redis/RedisPoolHeartbeatTest.php | 1 +
4 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/boost/todo.md b/src/boost/todo.md
index fae12764f..b8c600b55 100644
--- a/src/boost/todo.md
+++ b/src/boost/todo.md
@@ -25,7 +25,7 @@
## Database
-- 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.
+- Consider built-in early jitter for database and Redis 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
diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php
index b228fd693..651a2fae6 100644
--- a/src/redis/src/RedisConnection.php
+++ b/src/redis/src/RedisConnection.php
@@ -685,6 +685,7 @@ public function release(): void
$this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL);
$this->markInvalid();
} finally {
+ $this->database = null;
$this->availableForReuse = true;
parent::release();
}
@@ -707,6 +708,7 @@ public function isIdleExpired(?float $now = null): bool
return false;
}
+ // Heartbeat pings must not keep request-idle connections alive forever.
return ($now ?? microtime(true)) > $this->pool->getOption()->getMaxIdleTime() + $this->lastReleaseTime;
}
diff --git a/tests/Pool/HeartbeatConnectionTest.php b/tests/Pool/HeartbeatConnectionTest.php
index 2e9a52356..d7386cd3f 100644
--- a/tests/Pool/HeartbeatConnectionTest.php
+++ b/tests/Pool/HeartbeatConnectionTest.php
@@ -50,7 +50,7 @@ public function testConnectionCall(): void
$pool = $container->make(HeartbeatPoolStub::class);
/** @var KeepaliveConnectionStub $connection */
$connection = $pool->get();
- $connection->setActiveConnection($conn = new class {
+ $connection->setActiveConnection(new class {
public function send(string $data): string
{
return str_repeat($data, 2);
diff --git a/tests/Redis/RedisPoolHeartbeatTest.php b/tests/Redis/RedisPoolHeartbeatTest.php
index 0915d32cf..eb54fb50a 100644
--- a/tests/Redis/RedisPoolHeartbeatTest.php
+++ b/tests/Redis/RedisPoolHeartbeatTest.php
@@ -327,6 +327,7 @@ public function testReleaseResetFailureReturnsInvalidConnectionToPool(): void
$connection->release();
+ $this->assertNull((new ReflectionProperty(RedisConnection::class, 'database'))->getValue($connection));
$this->assertSame(1, $pool->getCurrentConnections());
$this->assertSame(1, $pool->getConnectionsInChannel());
From 7854168ae4054e920866377ade704af6320168a7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 24 Jun 2026 04:55:51 +0000
Subject: [PATCH 9/9] refactor(redis): simplify release database reset
Remove the redundant selected database reset inside the release try block now that release normalizes the marker from a single finally block.
---
src/redis/src/RedisConnection.php | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php
index 651a2fae6..867dbb23f 100644
--- a/src/redis/src/RedisConnection.php
+++ b/src/redis/src/RedisConnection.php
@@ -679,7 +679,6 @@ public function release(): void
$defaultDb = (int) ($this->config['database'] ?? 0);
if ($this->database !== null && $this->database !== $defaultDb) {
$this->select($defaultDb);
- $this->database = null;
}
} catch (Throwable $exception) {
$this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL);