Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.
API Index — Namespaces, Packages, Reports, Indices

RedisStreams
in package

FinalYes

Per-worker Redis Streams consumer runner.

The reliable counterpart to RedisPubSub. Owns one dedicated connection (XREADGROUP can block, doesn't fit the pool model) and one runner coroutine. Loops XREADGROUP COUNT $batchSize BLOCK $blockMs STREAMS ... across every registered stream, dispatches each entry via go(), XACKs the entry if the handler returns true. Non-true returns + any thrown exception leave the entry pending (retried on next reconnect / consumer recovery).

The block-timeout (default 1 s) is the natural wake-up cadence — the runner checks the stop flag every block window without needing a sentinel-channel trick like RedisPubSub.

Orphan recovery (XAUTOCLAIM): XREADGROUP with > only ever delivers NEW entries. An entry that was delivered to a consumer that then crashed/OOMed/was-recycled mid-handler — OR whose handler returned false / threw — is left PENDING and > never re-delivers it. Nothing would ever reclaim it, silently breaking the at-least-once promise for the exact failure mode it exists for. The runner therefore performs a periodic reclaim pass: roughly every $reclaimEverySec of wall time it XAUTOCLAIM-iterates each consumer's pending list (entries idle longer than $reclaimMinIdleMs), re-dispatching each reclaimed entry through the SAME handler + XACK-on-success path as a fresh read. A reclaim failure is caught + backed-off exactly like a read failure; it never kills the runner.

Table of Contents

Constants

DEFAULT_RECLAIM_EVERY_SEC  : mixed = 30
Default wall-time cadence (seconds) between orphan-reclaim passes.
DEFAULT_RECLAIM_MIN_IDLE_MS  : mixed = 60000
Default minimum idle time (ms) before a pending entry is eligible for reclaim.
RECLAIM_MAX_ITERATIONS  : mixed = 10000
Max XAUTOCLAIM cursor iterations per reclaim pass (drain-loop safety cap).

Properties

$ackPool  : RedisConnectionPool|null
Small dedicated pool of clients for XACK. Each ACKed message used to spin up a BRAND-NEW connection (connect + close per message), so a busy stream opened a TCP connection per delivery — a connection storm under load. A size-2 pool reuses connections across messages while still giving each dispatch coroutine a private socket (XACK from two cors on one socket would interleave RESP frames). Built lazily in the runner coroutine; closed by stop().
$consumerName  : string
$consumers  : array<int, array{stream: string, group: string, handler: callable, blockMs: int, batchSize: int}>
$opts  : array<string|int, mixed>
$reclaimCount  : int
Per-pass XAUTOCLAIM batch size (entries claimed per cursor iteration).
$reclaimEverySec  : int
Wall-time cadence (seconds) between orphan-reclaim passes.
$reclaimMinIdleMs  : int
Minimum idle time (ms) before a pending entry is reclaimed.
$running  : Atomic
$url  : string

Methods

__construct()  : mixed
consumerName()  : string
consumers()  : array<int, array{stream: string, group: string, handler: callable, blockMs: int, batchSize: int}>
isRunning()  : bool
reclaimEverySec()  : int
Configured wall-time cadence (seconds) between orphan-reclaim passes.
reclaimMinIdleMs()  : int
Configured minimum pending idle (ms) before a message is reclaimed.
reclaimPolicy()  : self
Tune the orphan-reclaim cadence + min-idle without re-constructing.
register()  : void
Register a stream + group + handler. Handler signature: function (string $payload, string $messageId, string $stream): bool Return true to XACK (message removed from pending). Return false OR throw to leave pending (retried on consumer recovery).
start()  : void
Spawn the runner coroutine. MUST be called from inside a coroutine context.
stop()  : void
Signal the runner to exit cleanly at the next BLOCK timeout.
ackPool()  : RedisConnectionPool
Lazily-built ACK connection pool (size 2). Constructing it is cheap — the pool only connects on first acquire(), which happens inside the dispatch coroutine. Reused across messages so a busy stream doesn't connect-per-XACK.
atomicIsZero()  : bool
backoffSeconds()  : float
dispatch()  : void
ensureGroups()  : void
reclaimAll()  : void
Run one orphan-reclaim pass across every registered consumer. Each consumer's pending list is XAUTOCLAIM-iterated (cursor walked to '0-0') and every reclaimed entry is re-dispatched through the same handler + XACK-on-success path as a fresh read.
reclaimPass()  : void
Drive the XAUTOCLAIM cursor for ONE consumer until it returns to '0-0' (end of the pending list), dispatching each reclaimed entry. Both the claim mechanism and the per-entry dispatch are injected so the cursor-iteration logic is unit-testable without a live RedisClient (the runner injects $client->xautoclaim() + the real dispatch(); a test injects a fake page source + a synchronous dispatch recorder).
runHandlerDecision()  : bool
Pure handler-decision core shared by read + reclaim dispatch. Runs the handler; on a strict true return it invokes $ack (the XACK action); a non-true return OR a thrown handler leaves the entry pending (no XACK) and is swallowed so the runner survives. Returns the decision (true = ACKed) for testability — the caller's $ack does the real network XACK on a private pooled socket.
runner()  : void
Group consumers by (group, blockMs, batchSize) so one XREADGROUP call can fetch from multiple streams sharing the same consumer-group params.

Constants

DEFAULT_RECLAIM_EVERY_SEC

Default wall-time cadence (seconds) between orphan-reclaim passes.

public mixed DEFAULT_RECLAIM_EVERY_SEC = 30

DEFAULT_RECLAIM_MIN_IDLE_MS

Default minimum idle time (ms) before a pending entry is eligible for reclaim.

public mixed DEFAULT_RECLAIM_MIN_IDLE_MS = 60000

RECLAIM_MAX_ITERATIONS

Max XAUTOCLAIM cursor iterations per reclaim pass (drain-loop safety cap).

private mixed RECLAIM_MAX_ITERATIONS = 10000

Properties

$ackPool

Small dedicated pool of clients for XACK. Each ACKed message used to spin up a BRAND-NEW connection (connect + close per message), so a busy stream opened a TCP connection per delivery — a connection storm under load. A size-2 pool reuses connections across messages while still giving each dispatch coroutine a private socket (XACK from two cors on one socket would interleave RESP frames). Built lazily in the runner coroutine; closed by stop().

private RedisConnectionPool|null $ackPool = null

$consumers

private array<int, array{stream: string, group: string, handler: callable, blockMs: int, batchSize: int}> $consumers = []

$reclaimCount

Per-pass XAUTOCLAIM batch size (entries claimed per cursor iteration).

private int $reclaimCount

$reclaimEverySec

Wall-time cadence (seconds) between orphan-reclaim passes.

private int $reclaimEverySec

$reclaimMinIdleMs

Minimum idle time (ms) before a pending entry is reclaimed.

private int $reclaimMinIdleMs

Methods

__construct()

public __construct(string $url[, string|null $consumerName = null ][, array{prefer?: "auto"|"phpredis"|"predis"} $opts = [] ][, int $reclaimEverySec = self::DEFAULT_RECLAIM_EVERY_SEC ][, int $reclaimMinIdleMs = self::DEFAULT_RECLAIM_MIN_IDLE_MS ][, int $reclaimCount = 64 ]) : mixed
Parameters
$url : string
$consumerName : string|null = null
$opts : array{prefer?: "auto"|"phpredis"|"predis"} = []

Driver preference for the runner's connections. Forced to ['prefer' => 'predis'] by App::wirePubSubBoot() under the H7 phpredis+HOOK_ALL=0 deadlock condition (predis blocking reads yield without HOOK_ALL).

$reclaimEverySec : int = self::DEFAULT_RECLAIM_EVERY_SEC

Wall-time cadence between orphan-reclaim passes (default 30s). 0 disables periodic reclaim entirely.

$reclaimMinIdleMs : int = self::DEFAULT_RECLAIM_MIN_IDLE_MS

Minimum pending idle time before a message is eligible for reclaim (default 60000ms = 60s) — keeps the reclaim from racing a still-running handler on a healthy consumer.

$reclaimCount : int = 64

XAUTOCLAIM batch size per cursor step (default 64).

consumerName()

public consumerName() : string
Return values
string

consumers()

public consumers() : array<int, array{stream: string, group: string, handler: callable, blockMs: int, batchSize: int}>
Return values
array<int, array{stream: string, group: string, handler: callable, blockMs: int, batchSize: int}>

isRunning()

public isRunning() : bool
Return values
bool

reclaimEverySec()

Configured wall-time cadence (seconds) between orphan-reclaim passes.

public reclaimEverySec() : int
Return values
int

reclaimMinIdleMs()

Configured minimum pending idle (ms) before a message is reclaimed.

public reclaimMinIdleMs() : int
Return values
int

reclaimPolicy()

Tune the orphan-reclaim cadence + min-idle without re-constructing.

public reclaimPolicy(int $everySec, int $minIdleMs[, int|null $count = null ]) : self

$everySec = 0 disables periodic reclaim. Returns $this for chaining.

Parameters
$everySec : int
$minIdleMs : int
$count : int|null = null
Return values
self

register()

Register a stream + group + handler. Handler signature: function (string $payload, string $messageId, string $stream): bool Return true to XACK (message removed from pending). Return false OR throw to leave pending (retried on consumer recovery).

public register(string $stream, string $group, callable $handler[, int $blockMs = 1000 ][, int $batchSize = 16 ]) : void
Parameters
$stream : string
$group : string
$handler : callable
$blockMs : int = 1000
$batchSize : int = 16

start()

Spawn the runner coroutine. MUST be called from inside a coroutine context.

public start() : void

stop()

Signal the runner to exit cleanly at the next BLOCK timeout.

public stop() : void

ackPool()

Lazily-built ACK connection pool (size 2). Constructing it is cheap — the pool only connects on first acquire(), which happens inside the dispatch coroutine. Reused across messages so a busy stream doesn't connect-per-XACK.

private ackPool() : RedisConnectionPool
Return values
RedisConnectionPool

atomicIsZero()

private static atomicIsZero(Atomic $a) : bool
Parameters
$a : Atomic
Return values
bool

backoffSeconds()

private static backoffSeconds(int $attempt) : float
Parameters
$attempt : int
Return values
float

dispatch()

private dispatch(array{stream: string, group: string, handler: callable, blockMs: int, batchSize: int} $entry, array{id: string, payload: array$msg) : void
Parameters
$entry : array{stream: string, group: string, handler: callable, blockMs: int, batchSize: int}
$msg : array{id: string, payload: array}

reclaimAll()

Run one orphan-reclaim pass across every registered consumer. Each consumer's pending list is XAUTOCLAIM-iterated (cursor walked to '0-0') and every reclaimed entry is re-dispatched through the same handler + XACK-on-success path as a fresh read.

private reclaimAll(RedisClient $client) : void

A reclaim error on one consumer is logged and skipped — it must NOT abort the whole pass or kill the runner (the StoreException is re-raised to the runner's read-path backoff so the connection is recycled, matching the read failure handling).

Parameters
$client : RedisClient

reclaimPass()

Drive the XAUTOCLAIM cursor for ONE consumer until it returns to '0-0' (end of the pending list), dispatching each reclaimed entry. Both the claim mechanism and the per-entry dispatch are injected so the cursor-iteration logic is unit-testable without a live RedisClient (the runner injects $client->xautoclaim() + the real dispatch(); a test injects a fake page source + a synchronous dispatch recorder).

private reclaimPass(callable(string): array{0: string, 1: list}>} $claim, callable(array{id: string, payload: array}): void $dispatch) : void
Parameters
$claim : callable(string): array{0: string, 1: list}>}
$dispatch : callable(array{id: string, payload: array}): void

runHandlerDecision()

Pure handler-decision core shared by read + reclaim dispatch. Runs the handler; on a strict true return it invokes $ack (the XACK action); a non-true return OR a thrown handler leaves the entry pending (no XACK) and is swallowed so the runner survives. Returns the decision (true = ACKed) for testability — the caller's $ack does the real network XACK on a private pooled socket.

private static runHandlerDecision(callable $handler, string $payload, string $id, string $stream, array<string, string> $fields, callable(): int $ack) : bool
Parameters
$handler : callable

the user handler (payload,id,stream,fields): bool

$payload : string
$id : string
$stream : string
$fields : array<string, string>

full field map of the entry

$ack : callable(): int

the XACK action (runs only on a true decision)

Return values
bool

runner()

Group consumers by (group, blockMs, batchSize) so one XREADGROUP call can fetch from multiple streams sharing the same consumer-group params.

private runner() : void

Streams under DIFFERENT groups run via separate XREADGROUP calls; the runner just rotates through them.

On this page