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.
// 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 ownTwo 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
$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 itStep 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:
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
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:
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.
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:
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: MainConfigurationsMainRedisConfigSeveral 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.
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.