Redis · Pool

Connection pool

Application code never sees the pool: it takes a store and works. This page is about what happens underneath, and about the three decisions a person still makes — how many connections, what to do when they run out, and what to wire in at startup.

How connections are handed out

Under Swoole

Every config class gets its own pool. The first call inside a coroutine borrows a connection from it and puts it in the coroutine context; a defer returns it when the coroutine ends — on a normal exit and on an exception alike.

php
// one coroutine = one connection for its whole life
$store->get('a');     // the connection is borrowed here
$store->set('b', 1);  // the same connection
                     // end of request — it went back on its own

Two consequences are worth keeping in mind. First: two coroutines never share a socket, so commands never interleave. Second: the connection is held for the whole life of the coroutine, not for the duration of a command — if a request talks to Redis at the start and again at the end, the pool counts it as busy in between.

Without Swoole

A process serves one unit of work at a time and there is nothing to distribute: each config gets one self-maintaining connection for the life of the process. Its liveness checks and lifetime are the same, so a long-running CLI worker does not wake up holding a socket the server closed hours ago.

Application code is identical in both cases — there is nothing to branch on.

What the pool does with connections

  • Probes before handing over. A connection idle for more than half a second is checked with PING; a dead one is retired and replaced. Hot connections skip the probe, so a busy service does not pay for it.
  • Watches their age. A connection older than half an hour is reopened — that is how the pool keeps up with infrastructure moving underneath it: proxies, load balancers, failover addresses.
  • Caps their number. A thousand concurrent requests do not become a thousand connections: the extra ones wait their turn.

All of this is the machinery of CPool, shared with PPA.

What happens during one request

php
$store->get('a');       // 1. the pool hands over a connection, the defer is armed
$store->set('b', 1);    // 2. the same connection, from the coroutine context
$store->hash('c')->all();  // 3. and again the same one
                       // 4. the coroutine ended — the defer returned it

Step by step on that first call: the pool looks for a free connection; if there is one and it has been idle longer than aliveBypassWindow, it is probed with PING and replaced if dead; if none is free and the ceiling is not reached, a new one is opened; if the ceiling is reached, the borrower waits up to poolWaitTimeout. The connection obtained goes into the coroutine context, and a defer is registered.

Every later call in that coroutine takes the connection from the context — no further probes, no further borrowing. The defer fires on a normal exit, on an exception and on exit alike, so a connection cannot be lost even by a request that crashed.

Pool size

The defaults are 10 connections per config and a 3 second wait. Change them through a pool-aware config.

Setting Default What it does
$poolMaxConnections 10 ceiling on the number of connections
$poolWaitTimeout 3.0 how long to wait for a free one
$keepaliveTime 0.0 background ping of idle connections; 0 is off
$idleTimeout 0.0 close idle connections; 0 never closes
$minimumIdle 0 warm connection floor

How to size the ceiling: count across all workers, not one. The number the server sees is worker_num × poolMaxConnections × instances, and it should be compared with your Redis maxclients (10 000 by default — considerably more generous than a database’s max_connections, which is why the default here is higher than PPA’s).

keepaliveTime is for when a firewall or Redis itself kills idle connections: a periodic ping stops them dying between requests. idleTimeout is for bursty traffic, where holding the peak-sized pool all night is wasteful; that is also when you set minimumIdle, so the next burst does not start from zero.

With the defaults there is no background timer at all

Housekeeping only starts when keepaliveTime or idleTimeout is set. An idle pool costs nothing and holds nothing in the worker’s reactor.

Exhaustion

When every connection is busy, the borrower waits. If nothing frees up within poolWaitTimeout:

php
RedisPoolException: RedisPool: no connection for [Main\Configurations\MainRedisConfig] 
ConnectionPool: no free connection within 3s raise maximumPoolSize or connectionTimeout.

That is a guard rail, not a failure: a queue inside the application beats max number of clients reached on the server, which takes the neighbouring applications down with it.

Before raising the ceiling, look for slow requests

A connection is held for the whole life of the coroutine. A request that talks to Redis and then waits half a second on an external API holds its connection all that time — and a pool of ten serves twenty requests a second instead of thousands. Raising the ceiling hides that rather than fixing it.

Observing it

php
use Flytachi\Winter\Redis\RedisPool;

RedisPool::stats();
// [
//   'Main\Configurations\MainRedisConfig' => [
//       'total' => 4, 'idle' => 3, 'active' => 1, 'maximum' => 10,
//   ],
// ]

The numbers are per worker: each worker keeps its own pool in its own memory, so a request to /actuator/health shows the worker that served it. The non-coroutine path has no pool and is not reported.

active sitting at maximum while borrowers wait is the signal to raise the ceiling — or to find the code that borrows and never lets go.

A broken connection

Usually there is nothing to do: ext-redis reconnects transparently — verified both on an explicit close() and on a server-side CLIENT KILL, with the selected database and the authentication restored on its own.

When a command fails and the connection is in doubt, it can be reported:

php
use Flytachi\Winter\Redis\RedisPool;

try {
  $store->set('key', 'value');
} catch (\RedisException $e) {
  RedisPool::reportFailure(MainRedisConfig::class, $e);
  throw $e;
}

The verdict comes from a probe, not from reading the error text: the pool sends one PING. If it answers, the connection is alive, the fault was in the command, and the connection stays in the pool. If it does not, the connection is evicted and the next command gets a new one. The failed command is not retried: the pool cannot know whether the server had already applied it, and a retry could double a write.

A connection outside the pool

Some commands occupy their connection for as long as they run: BLPOP, BRPOP, SUBSCRIBE, MONITOR. Issuing one on a pooled connection holds a pool slot for exactly that long, and a few consumers will empty the pool while Redis itself sits idle.

php
use FlytachiWinterRedisRedisPool;

$config = RedisPool::dedicated(MainRedisConfig::class);
$redis  = $config->connection();

$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);   // let the blocking read wait as long as it needs
$redis->subscribe(['events'], $handler);

$config->disconnect();

dedicated() opens a connection that is never shared and never returned: the caller owns it. Such a connection does not appear in stats() — the pool knows nothing about it and its ceiling does not cover it, so counting them is on you.

For queues this is already done: $list->consume() takes such a connection itself and releases it on close().

RedisPool reference

A static facade; application code rarely needs it — the stores use it.

Method What it does
store($configClass) the client of the current unit of work; pooled under Swoole, the single connection without it
config($configClass) the registration config instance — for reading settings, not for connecting
dedicated($configClass) a new connection outside the pool, owned by the caller
stats() utilisation of each pool: total, idle, active, maximum
showConfigs() every registered config, for health checks
reportFailure($configClass, $error) report a failure on the borrowed connection
setLogger($logger) where pool events go
shutdown() close everything this process opened — at worker exit
reset() forget everything without closing — in a child after fork()

Which error means what

Exception When What to do
RedisPoolException no connection was obtained: the pool is full, or the server is unreachable check stats() and the cause in getPrevious()
RedisCommandException the server refused the command: wrong key type, a counter that is not a number a bug in the code, not in the infrastructure
RedisFeatureException the command is newer than the server (hash field lifetimes) upgrade the server, or make do with expireKey()
RedisException (driver) a dropped connection, a read timeout the connection is suspect — reportFailure()

The first three are ours; the last comes from ext-redis unchanged.

What lands in the log

With a logger set, the pool narrates itself at debug level and reports losses at warning:

bash
DEBUG  config registered: MainConfigurationsMainRedisConfig redis://localhost:6379/0
DEBUG  pool created: MainConfigurationsMainRedisConfig maxConnections=10
DEBUG  slot opened: MainConfigurationsMainRedisConfig redis://localhost:6379/0
DEBUG  cid=7 borrow: MainConfigurationsMainRedisConfig
DEBUG  cid=7 release: MainConfigurationsMainRedisConfig
WARN   cid=9 evict: MainConfigurationsMainRedisConfig

Several slot opened in a row mean the pool is growing under load; frequent evict means connections are dying between requests, and keepaliveTime is worth a look.

Wiring it into the application lifecycle

Three calls in the bootstrap. All optional, each closing a specific hole.

main/bootstrap.php
use Flytachi\Winter\Kernel\Process\ForkReset;
use Flytachi\Winter\Logger\LoggerFactory;
use Flytachi\Winter\Redis\RedisPool;

// 1. Pool logging: slots opened, borrows, releases, evictions.
RedisPool::setLogger(LoggerFactory::getLogger(RedisPool::class));

// 2. Fork safety: a child must forget inherited sockets without closing them.
ForkReset::register(static fn() => RedisPool::reset());

Why forking needs its own line

fork() copies file descriptors, so a connection opened before the fork is shared by parent and child — and the protocol breaks for both. reset() forgets the connections without closing them: closing would tear down the parent’s connection. Winter’s daemons and processes fork, so this registration matters.

The third call — RedisPool::shutdown() — is only needed if you enabled keepaliveTime or idleTimeout: those arm a timer, and a worker cannot exit while its reactor still holds a repeating one. With the defaults there is no timer and nothing to do.

PPA gets this from the kernel; Redis is still by hand

The kernel registers the fork reset and the pool shutdown for the database itself (PpaConnectionPool). For winter-redis it does not yet: the package is standalone and not among the kernel’s dependencies, so the two lines above belong in the application. Once the package moves into the kernel they become unnecessary.

Next

  • Stores — what application code sees
  • CPool — the pool itself, if you need to plug your own driver into it
  • PPA — the same approach for the database