Skip to content
14 changes: 11 additions & 3 deletions src/boost/docs/redis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
],

Expand All @@ -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)),
],
],
],
Expand Down Expand Up @@ -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.

<a name="interacting-with-redis"></a>
## Interacting With Redis
Expand Down
7 changes: 1 addition & 6 deletions src/boost/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
20 changes: 15 additions & 5 deletions src/foundation/config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
],

Expand All @@ -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)),
],
],

Expand All @@ -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)),
],
],

Expand All @@ -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)),
],
],

Expand All @@ -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)),
],
],
],
Expand Down
30 changes: 13 additions & 17 deletions src/pool/src/KeepaliveConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*/
Expand Down
3 changes: 1 addition & 2 deletions src/redis/src/PhpRedisClusterConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 1 addition & 2 deletions src/redis/src/PhpRedisConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
164 changes: 164 additions & 0 deletions src/redis/src/Pool/RedisPool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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();
}

/**
Expand All @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

$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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

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

Comment thread
greptile-apps[bot] marked this conversation as resolved.
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);
}
}
Loading