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/boost/todo.md b/src/boost/todo.md
index 4a6bf0247..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
@@ -49,11 +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
-
-- 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
- 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.
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)),
],
],
],
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/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/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);
+ }
}
diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php
index ccb238ad2..867dbb23f 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.
*/
@@ -592,12 +679,14 @@ public function release(): void
$defaultDb = (int) ($this->config['database'] ?? 0);
if ($this->database !== null && $this->database !== $defaultDb) {
$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->database = null;
+ $this->availableForReuse = true;
+ parent::release();
}
}
@@ -609,6 +698,41 @@ 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;
+ }
+
+ // Heartbeat pings must not keep request-idle connections alive forever.
+ 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 +771,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.
*/
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',
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/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..d7386cd3f 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)
+ $connection->setActiveConnection(new class {
+ 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;
diff --git a/tests/Redis/RedisPoolHeartbeatTest.php b/tests/Redis/RedisPoolHeartbeatTest.php
new file mode 100644
index 000000000..eb54fb50a
--- /dev/null
+++ b/tests/Redis/RedisPoolHeartbeatTest.php
@@ -0,0 +1,688 @@
+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->assertNull((new ReflectionProperty(RedisConnection::class, 'database'))->getValue($connection));
+ $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',