API Index — Namespaces, Packages, Reports, Indices
RedisSessionHandler
in package
implements
SessionHandlerInterface
Redis-backed session handler for ZealPHP.
Reads/writes session data using the same key format as PHP's phpredis session handler (PHPREDIS_SESSION:{session_id}), so sessions created by Apache/mod_php are readable by ZealPHP and vice versa.
Coroutine safety (issue #16)
phpredis (\Redis) is not coroutine-safe: a single connection multiplexed
across concurrent coroutines interleaves request/response frames on the same
socket, so one coroutine can read another's reply (or garbage). Sharing one
handler instance — the common onWorkerStart pattern — therefore corrupted
session reads under load, which write_close() then persisted (a 24-key
session collapsing to a handful of keys).
This handler keeps one connection per coroutine, stored in the coroutine's
context, so concurrent requests never share a socket. The socket is closed
deterministically via Coroutine::defer() when the coroutine ends (issue #438
— relying on context GC leaked one FD per request under HOOK_ALL until
maxclients was exhausted; see conn()). Outside a coroutine (CLI, tests) it
uses a single lazily-created fallback connection. High-throughput deployments
that want to avoid per-request connection churn should front this with a
connection pool; the per-coroutine model here is correct-by-default.
Every method requires a live Redis connection, so it is verified against a real server rather than unit tests — excluded from coverage measurement (no offline seam without shipping a Redis mock).
Tags
Table of Contents
Interfaces
- SessionHandlerInterface
Properties
- $baseData : array<string, string>
- Per-coroutine read snapshot for 3-way merge on write conflict.
- $fallback : Redis|null
- Single connection used outside coroutine context (CLI / tests).
- $host : string
- Redis host (default
'127.0.0.1'). - $port : int
- Redis port (default
6379). - $prefix : string
- Key prefix used for session entries (default
'PHPREDIS_SESSION:'). - $ttl : int
- Session TTL in seconds (default
1440).
Methods
- __construct() : mixed
- close() : bool
- No-op: the per-coroutine connection is closed by the
Coroutine::defer()registered inconn()(#438), not by the session-manager lifecycle. - destroy() : bool
- Delete the session key from Redis and return
true. - gc() : int|false
- Garbage collection — Redis TTL handles expiry server-side, so this is a no-op.
- getRedis() : Redis
- Expose the Redis connection for the current coroutine (or the fallback).
- merge3Array() : array<string|int, mixed>
- Recursive 3-way array merge.
- open() : bool
- Verify that the Redis connection is alive. Called by PHP's session manager
before the first
read(). - parseSession() : array<string|int, mixed>
- Parse a PHP session-encoded string into a key→value array.
- read() : string
- Read and return the serialised session data for
$sessionId. - serializeSession() : string
- Serialise a key→value array back to the PHP session-encoded string format.
- write() : bool
- Persist serialised session data for
$sessionIdwith optimistic locking. - conn() : Redis
- Resolve the Redis connection for the CURRENT context.
- connect() : Redis
- Open a new
\Redisconnection to$this->host:$this->port. - io() : mixed
- Run a Redis save-handler operation, guaranteeing it executes inside a coroutine.
- merge3Sessions() : string
- 3-way merge for serialised PHP session strings.
Properties
$baseData
Per-coroutine read snapshot for 3-way merge on write conflict.
private
array<string, string>
$baseData
= []
$fallback
Single connection used outside coroutine context (CLI / tests).
private
Redis|null
$fallback
= null
$host
Redis host (default '127.0.0.1').
private
string
$host
$port
Redis port (default 6379).
private
int
$port
$prefix
Key prefix used for session entries (default 'PHPREDIS_SESSION:').
private
string
$prefix
$ttl
Session TTL in seconds (default 1440).
private
int
$ttl
Methods
__construct()
public
__construct([string $host = '127.0.0.1' ][, int $port = 6379 ][, string $prefix = 'PHPREDIS_SESSION:' ][, int $ttl = 1440 ]) : mixed
Parameters
- $host : string = '127.0.0.1'
-
Redis host.
- $port : int = 6379
-
Redis port.
- $prefix : string = 'PHPREDIS_SESSION:'
-
Key prefix; use the phpredis default (
'PHPREDIS_SESSION:') for cross-handler compatibility. - $ttl : int = 1440
-
Session TTL in seconds.
close()
No-op: the per-coroutine connection is closed by the Coroutine::defer()
registered in conn() (#438), not by the session-manager lifecycle.
public
close() : bool
close() deliberately does NOT close the socket: PHP can call the
save-handler close more than once per coroutine (every
session_write_close()), and the socket is shared across all session
operations in the request — closing it here would break a later
read()/write() on the same coroutine. The deferred close fires exactly
once, when the coroutine ends.
Return values
booldestroy()
Delete the session key from Redis and return true.
public
destroy(mixed $sessionId) : bool
Parameters
- $sessionId : mixed
Return values
boolgc()
Garbage collection — Redis TTL handles expiry server-side, so this is a no-op.
public
gc(mixed $maxlifetime) : int|false
Returns 0 (zero sessions collected) to satisfy the SessionHandlerInterface contract.
Parameters
- $maxlifetime : mixed
Return values
int|falsegetRedis()
Expose the Redis connection for the current coroutine (or the fallback).
public
getRedis() : Redis
Useful for inspecting connection state in tests or running additional Redis commands in the same per-coroutine socket.
Return values
Redismerge3Array()
Recursive 3-way array merge.
public
static merge3Array(array<string|int, mixed> $base, array<string|int, mixed> $local, array<string|int, mixed> $remote) : array<string|int, mixed>
Strategy: remote is the baseline result; for each key in local:
- If the key is new in local (not in base), add it to the result.
- If both local and remote are arrays, recurse.
- If local changed from base, local wins. Keys deleted locally (present in base, absent in local) are removed from the result only when the remote value is still the base value (i.e. nobody else changed it).
Parameters
- $base : array<string|int, mixed>
- $local : array<string|int, mixed>
- $remote : array<string|int, mixed>
Return values
array<string|int, mixed>open()
Verify that the Redis connection is alive. Called by PHP's session manager
before the first read().
public
open(mixed $savePath, mixed $sessionName) : bool
Parameters
- $savePath : mixed
- $sessionName : mixed
Return values
boolparseSession()
Parse a PHP session-encoded string into a key→value array.
public
static parseSession(string $data) : array<string|int, mixed>
Uses the php serialisation format (key|serialized_value). Unknown or
malformed entries are silently skipped. Only stdClass objects are
allowed in unserialize() (matches the whitelist in src/Session/utils.php).
Parameters
- $data : string
Return values
array<string|int, mixed>read()
Read and return the serialised session data for $sessionId.
public
read(mixed $sessionId) : string
Also WATCHes the key so a concurrent write is detected during the
subsequent write() call; stores the baseline data in $baseData for
the 3-way merge.
Parameters
- $sessionId : mixed
Return values
stringserializeSession()
Serialise a key→value array back to the PHP session-encoded string format.
public
static serializeSession(array<string|int, mixed> $data) : string
Parameters
- $data : array<string|int, mixed>
Return values
stringwrite()
Persist serialised session data for $sessionId with optimistic locking.
public
write(mixed $sessionId, mixed $sessionData) : bool
Uses WATCH/MULTI/EXEC and retries up to 3 times on conflict. When
a concurrent writer is detected, performs a 3-way merge (base = original
read() snapshot, local = intended write, remote = current Redis value)
before retrying. Returns false if all 3 attempts fail.
Parameters
- $sessionId : mixed
- $sessionData : mixed
Return values
boolconn()
Resolve the Redis connection for the CURRENT context.
private
conn() : Redis
In a coroutine: a per-coroutine socket stored in the coroutine context
(issue #16 — concurrent coroutines must never share a socket and interleave
RESP frames). Outside a coroutine: the shared $fallback. The connection
is established lazily here.
The per-coroutine socket is closed deterministically via
Coroutine::defer() (issue #438), NOT by context GC. Under HOOK_ALL the
socket is hooked I/O; once the coroutine has already exited there is no live
coroutine left to drive its hooked close, so relying on context teardown
leaked one FD per request (visible as CLOSE-WAIT sockets) until the worker
eventually exhausted Redis/Valkey's maxclients. The defer closure runs
while the coroutine is still alive (just before context teardown), so the
hooked close() can yield and the FD is released. Registered exactly once,
on first creation, so a single deferred close fires per coroutine.
Callers reach this only through io(), which guarantees a coroutine is live
before connect()'s hooked \Redis->connect() runs.
Return values
Redisconnect()
Open a new \Redis connection to $this->host:$this->port.
private
connect() : Redis
Return values
Redisio()
Run a Redis save-handler operation, guaranteeing it executes inside a coroutine.
private
io(callable(Redis): mixed $op) : mixed
Every \Redis call in this handler — the connect() plus
watch/get/multi/exec/del — is hooked I/O under
OpenSwoole\Runtime::HOOK_ALL, so it FATALS with
"API must be called in the coroutine" when the PHP session save-handler
chain fires OUTSIDE a request coroutine. That happens under
App::superglobals(true) WITHOUT enableCoroutine(true): the onRequest
handler isn't auto-wrapped in a coroutine, yet HOOK_ALL still hooks \Redis
— e.g. an app that installs its own save handler via sessionLifecycle(false)
and calls session_start() from middleware. #271 made the constructor
lazy, but open()/read() are themselves "first use" and still hit the wall
(#285).
When already in a coroutine, run $op directly on the per-coroutine
connection. When NOT, run it inside Coroutine::run() on the shared
$fallback (the App::parallel / #261 sync-mode idiom — Coroutine::run
swallows throws, so capture + rethrow to keep a Redis error a catchable
exception, not a worker-killing fatal). $fallback persists across these
sequential transient runs, so the connection — and WATCH (read) ->
MULTI/EXEC (write) optimistic locking — spans the whole request.
Validated against a live Redis: a hooked socket created in one
Coroutine::run() is reused, with WATCH/MULTI/EXEC intact, in later
runs. (In this no-request-coroutine mode a worker handles requests
sequentially, so there is no concurrent writer for the lock to guard anyway.)
Parameters
- $op : callable(Redis): mixed
merge3Sessions()
3-way merge for serialised PHP session strings.
private
merge3Sessions(string $base, string $local, string $remote) : string
Gives leaf-level granularity — concurrent writes to disjoint leaf
paths under the same top-level key (e.g. $_SESSION['cart']['item1']
vs $_SESSION['cart']['item2']) both survive.
Parameters
- $base : string
- $local : string
- $remote : string