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.

Package flytachi/winter-redisBuilt on CPoolDefault 10 connections per worker

What the pool is and why

The problem. A Redis connection is one socket with a sequential protocol: a command, a reply, the next command. While one request is using it, a second cannot go through. In classic PHP that did not matter — the process served one request and died. A resident worker serves many requests at once, each in its own coroutine, and one shared connection there means two coroutines writing into it interleaved: a reply arrives for somebody else’s request, packets out of order shows up in the log, and under load the whole worker goes down.

Opening a connection per request is the other extreme: a handshake, authentication and a database select for every little thing, and a thousand simultaneous requests turn into a thousand connections and max number of clients reached on the server.

The solution. A set of ready connections a coroutine borrows one of for the duration of its work and returns when it ends. Connections are reused, their number has a ceiling, dead ones are replaced. Application code does not change at all: it takes a store and works.

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 deadline for a whole borrow: waiting, discarding dead ones, reopening
$keepaliveTime 120.0 background ping of idle connections; 0 is off
$idleTimeout 600.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).

Housekeeping is on by default, and for Redis that matters more than for the database: the pool here is ten per worker, so a pause during which the server closed the sockets left ten corpses for the next request to find. keepaliveTime pings idle connections every two minutes — exactly what Redis’s own timeout directive, which many managed offerings use to close inactive clients, would otherwise do to them. idleTimeout hands the connections back after ten idle minutes instead of holding worker_num × 10 of them overnight.

You switch them off in the opposite cases: keepaliveTime when Redis is next door and connections are cheap, idleTimeout when the pool should stay warm between bursts — and that is also when you set minimumIdle, so the next burst does not start from zero.

The timer is armed by the first borrow

Until a connection has been borrowed there is no timer and the pool costs nothing. After the first borrow it lives until RedisPool::shutdown() — see the warning below.

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.

Which error means what

Exception When What to do
RedisPoolException no live connection was obtained: the pool is full, the server is unreachable, or it accepts the socket without serving on it read the message — it names which; then stats() and 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: check whether keepaliveTime has been switched off, and whether something is cutting them faster than once every two minutes.

Wiring it into the application lifecycle

There is nothing to do. The kernel wires the pool up itself, at startup, when the package is installed:

text
Kernel::init()
└── DepSupport::has(Dep::Redis)
    ├── RedisPool::setLogger(...)                    pool logs go to the 'Redis' channel
    └── ForkReset::register(RedisPool::reset(...))   fork safety

workerExit
└── RedisPool::shutdown()                             closing at worker exit

You can confirm it took effect from the log: at debug level the pool starts talking straight away — config registered, pool created, slot opened.

Why forking is a concern of its own

fork() copies file descriptors, so a connection opened before the fork is physically the same one in parent and child. Two commands sent into one socket from two processes break the protocol for both, and the symptom — packets out of order, or a reply to somebody else’s request — points nowhere near the fork.

reset() forgets the inherited connections without closing them: closing would tear down the parent’s connection. The child opens its own on first use.

Winter’s daemons and processes fork, so this registration matters — and the kernel does it for you.

If the application boots without the kernel

The package is standalone: it reaches for none of the framework’s globals, which is exactly what lets it be used without Winter. In such an application the same two lines are written by hand:

RedisPool::setLogger(LoggerFactory::getLogger(RedisPool::class));
ForkReset::register(static fn() => RedisPool::reset());

And RedisPool::shutdown() at process exit. That one is always mandatory: housekeeping is on by default, so a pool that has served a single borrow holds a timer, and a worker cannot exit while its reactor still holds a repeating one.


RedisPool reference

RedisPool is a static facade over every pool in the process. Application code rarely needs it: the store hands out the client, and the application’s bootstrap handles closing and resetting. The methods are described here because diagnostics go through them, and because two of them do almost the same thing — and mixing those two up is expensive.

Access is static, with no dependency injection: the pool holds process-wide state, and there cannot be a second instance of it.

store()

Returns the client of the current unit of work.

Under Swoole it borrows a connection from the pool on the first call within a coroutine, caches it in the coroutine’s context and registers a defer that returns it when the coroutine ends. Without Swoole it hands out the process’s single connection. This is the method RedisStore::raw() calls.

Syntax

php
public static function store(string $configClass): \Redis

Parameters

$configClass — the configuration class, MainRedisConfig::class.

Returns

A \Redis client already bound to the current coroutine. There is no need to release it by hand.

Errors

RedisPoolException — if the borrow never reached a live connection within poolWaitTimeout: everything busy, a connection that could not be opened, or a window spent on dead connections (the server accepts the socket but does not serve on it). The real cause is in getPrevious().

config()

Returns the registration instance of a configuration.

For reading settings or checking reachability without occupying a pooled connection: the address, the database index, ping().

Syntax

php
public static function config(string $configClass): RedisConfigInterface

Parameters

$configClass — the configuration class.

Returns

The configuration instance; created once per class and reused.

The instance from `config()` is not the one in the pool

It exists so settings can be read. The connections are held by other instances — one per pool slot, and those are the ones the pool probes and closes.

dedicated()

Opens a new connection outside the pool and hands ownership to the caller.

For blocking commands: SUBSCRIBE, BLPOP, XREAD BLOCK occupy a connection for the whole wait, and holding a pooled one for that would take it away from ordinary requests.

Syntax

php
public static function dedicated(string $configClass): RedisConfigInterface

Parameters

$configClass — the configuration class.

Returns

A new configuration instance with its connection already open. Closing it is the caller’s job.

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

stats()

Returns the utilisation of every pool.

Counted for the current worker: each worker has its own pool, and a request sees the one that served it.

Syntax

php
public static function stats(): array

Parameters

None.

Returns

An array keyed by configuration class, each value four numbers: total (open in all), idle (free), active (handed out) and maximum (the ceiling).

Without Swoole the array is empty: there are no pools there.

showConfigs()

Returns every configuration registered in the process.

The door for health checks: walk the list and poll each connection point with pingDetail() without knowing in advance how many there are.

Syntax

php
public static function showConfigs(): array

Parameters

None.

Returns

An array of configuration instances keyed by class name. Only the ones already used appear in it: registration is lazy.

reportFailure()

Reports to the pool a failure that happened on a borrowed connection.

The method decides whether the connection is to blame: a lost connection is evicted and the next request gets a new one; a refused command means the connection is healthy and nobody touches it.

Syntax

php
public static function reportFailure(string $configClass, \Throwable $error): bool

Parameters

$configClass — the configuration whose connection failed.

$error — the exception exactly as the driver threw it.

Returns

true if the error was classified as a connection loss and the connection was evicted; false if the server is healthy.

setLogger()

Sets where the pool writes its events.

Syntax

php
public static function setLogger(LoggerInterface $logger): void

Parameters

$logger — any PSR-3 logger. By default the pool stays silent.

shutdown()

Closes every pool and connection the process owns.

Called when a worker stops. Besides the sockets it releases the housekeeping timer: a live timer keeps the worker’s reactor from draining.

Syntax

php
public static function shutdown(): void

reset()

Forgets every pool and connection without closing them.

Called in a child process straight after fork(). The difference from shutdown() is fundamental: a fork copies file descriptors, so a socket inherited from the parent is physically the same one — close it in the child and you tear down the parent’s connection.

Syntax

php
public static function reset(): void

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