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',