Redis · Streams

Streams

A stream is an append-only log: every entry has an identifier the server assigns and a set of fields. It differs from a list in that reading does not take the entry away, and consumer groups make Redis itself keep the books: who took what, and what has not been acknowledged.

What a stream is

An entry looks like this: the identifier 1755600000123-0 (milliseconds of the server clock and a sequence number within that millisecond) and the fields {"type": "signup", "user": "42"}. Identifiers increase monotonically, which makes “read everything after this one” an ordinary operation rather than a search.

The properties everything else follows from:

  • Reading takes nothing away. An entry lives until it is deleted or trimmed away. Any number of independent readers can see the same one.
  • Order is guaranteed and matches the order entries were added in.
  • The log grows forever unless bounded. That is the price of keeping history.
  • Groups are the server’s business. It remembers which entry went to which consumer and whether it was acknowledged. A list cannot do that — there the bookkeeping is yours.

How this differs from a list

List Stream
After reading the element is gone the entry stays
Readers per message one as many as you like
History none until it is trimmed
“Taken but unfinished” by hand (moveTo + remove) built in, per group
Growth bounded by consumption forever, unless bounded
Identifiers none yes, used for reading and acknowledging

If you need a queue and no history, take a list: simpler and cheaper. Streams are for history, several independent readers, or built-in delivery tracking.

Where streams belong

An event bus. One event, several independent handlers: an email, a metric, a webhook. Each reads through its own group without disturbing the others.

A queue with a guarantee. A worker took an entry and died — it stays in the group’s unfinished list and goes to another worker. Exactly what has to be assembled from moveTo() and remove() with lists.

A change log. An audit trail, an order’s history, an activity feed: re-read it, rewind it, reconstruct an incident from it.

A buffer with catch-up reads. A handler writes, a consumer reads at its own pace and after a restart continues where it stopped.

Where a stream is the wrong choice:

The task Why not a stream What instead
A plain queue with no history needless growth, needless concepts a list
Notify everyone connected right now a stream stores, it does not broadcast pub/sub
Find an entry by its content no search, only identifiers and ranges an index on sets
Keep an object’s state streams are about events, not current values a hash

The handle

php
$events = $store->stream('events');    // the server sees 'session:events'

stream() executes nothing — it is a view, not a request. The prefix is applied here, once. The handle holds no connection either, with one exception: follow() opens one of its own, because a blocking read occupies its connection for the whole wait.

Entries

Reading methods return StreamEntry:

php
foreach ($events->range(count: 100) as $entry) {
  $entry->id;                  // '1755600000123-0'
  $entry->fields;              // ['type' => 'signup', 'user' => '42']
  $entry->get('type');         // 'signup'
  $entry->get('absent', '');  // with a default
  $entry->has('user');         // bool
  $entry->timestamp();         // 1755600000123 — milliseconds of the server clock
}

The driver hands entries over as ['1755600000123-0' => ['type' => 'signup']] — an array whose single key is the identifier. Iterating that means reaching for key()/current(), and the identifier is needed constantly: it is what acknowledges work and what a reader remembers as its position.


RedisStream reference

Writing

add()

php
public function add(array $fields, ?int $cap = null, bool $exact = false, string $id = '*'): string

Appends an entry to the end of the log.

Argument Type Default What it does
$fields array the entry’s fields. At least one: Redis has no empty entries
$cap ?int null bound the log to roughly this many entries
$exact bool false trim to exactly $cap instead of approximately
$id string '*' the identifier; * lets the server assign one

Returns the identifier the entry was given. Throws RedisCommandException when the key holds a structure of another type.

php
$events->add(['type' => 'signup', 'user' => '42']);
// '1755600000123-0'

$events->add(['level' => 'warn', 'msg' => $text], cap: 10_000);

Your own identifiers are almost never needed: the server’s are monotonic and carry time. Assigning them by hand only makes sense when importing from another log.

Field values are strings

As everywhere in Redis. An array in a field becomes "Array" unless the config sets a serializer.

trim()

php
public function trim(int $cap, bool $exact = false): int

Keeps roughly the newest $cap entries.

Argument Type Default What it does
$cap int how many entries to keep
$exact bool false trim exactly instead of approximately

Returns how many entries were removed.

Approximate trimming may remove nothing

It stops at a node boundary of the stream’s internal representation — cheaper, and what Redis itself recommends. The non-obvious consequence: on a short log trim(10) may well return 0 and leave everything in place. That is not a failure; if you need a predictable length, ask for exact: true and pay for the walk.

php
$events->trim(10_000);                 // cheap, the length is "about right"
$events->trim(10_000, exact: true);    // exactly that many, more expensive

trimBefore()

php
public function trimBefore(string $id, bool $exact = false): int

Removes everything older than the given identifier.

Argument Type Default What it does
$id string the boundary; entries with a smaller identifier go
$exact bool false an exact boundary instead of an approximate one

Returns how many entries were removed.

This is how a log is bounded by time rather than by count: an identifier begins with the milliseconds of the server clock, so “older than a day” is an identifier:

php
$events->trimBefore((string) ((time() - 86400) * 1000));

delete()

php
public function delete(string ...$ids): int

Removes entries by identifier. Returns how many existed. With no arguments, 0.

Deleting from the middle of a log is allowed but exceptional: the ordinary way to bound a stream is trimming. delete() is for the entry that must not be readable again.

Reading

count()

php
public function count(): int

The number of entries; 0 for a missing key. No arguments.

range()

php
public function range(?string $from = null, ?string $to = null, ?int $count = null): array

Entries in the order they were added, taking nothing out.

Argument Type Default What it does
$from ?string null from which identifier, inclusive; null = the beginning
$to ?string null to which, inclusive; null = the end
$count ?int null at most this many entries

Returns list<StreamEntry>; an empty array for an empty log.

php
$events->range();                    // everything
$events->range(count: 100);          // the first hundred
$events->range($since, count: 50);   // from a known position

reverse()

php
public function reverse(?int $count = null): array

The same, newest first — the cheap way to look at what just happened.

php
$events->reverse(count: 10);   // the last ten, freshest first

after()

php
public function after(string $id, int $count = 10): array

Entries added after the given identifier, without waiting.

Argument Type Default What it does
$id string the position to read after
$count int 10 at most this many entries

Returns list<StreamEntry>; an empty array when there is nothing new.

This is the building block of a reader that keeps its own position: remember the identifier of the last entry handled, pass it back next time. Two such readers do not disturb each other — a stream is not consumed by reading.

php
$position = $this->positions->get('events') ?? '0';

foreach ($events->after($position, count: 100) as $entry) {
  $this->handle($entry);
  $position = $entry->id;
}

$this->positions->set('events', $position);

follow()

php
public function follow(float $timeout = 0.0, int $count = 10, ?string $from = null): array

Waits for entries newer than the ones this handle has already seen.

Argument Type Default What it does
$timeout float 0.0 seconds to wait; 0 waits indefinitely
$count int 10 at most this many entries per call
$from ?string null the position for the first call; '0' starts with all history

Returns list<StreamEntry>; an empty array when nothing arrived in time.

main/Daemons/EventTail.php
$events = $store->stream('events');

while (!$this->stopping) {
  foreach ($events->follow(timeout: 5) as $entry) {
      $this->handle($entry);
  }
}

$events->close();

The position is remembered — and why not «$»

The first call starts at the current end of the log and spends one extra round trip finding out where that is. Redis’s own $ (“entries added after this call begins”) would seem to do the job, but there is a gap between two calls of a loop, and everything written in it would fall into neither — work would vanish without a trace. Resolving the position once turns the tail into an unbroken chain of concrete identifiers.

History is deliberately not read through follow(): range() and after() are for that.

follow() does not take a pooled connection

A blocking read occupies its connection for the whole wait, so the handle opens one of its own. close() releases it. For the same reason the handle is worth keeping for the whole loop rather than re-taking it each iteration.

Observation

info()

php
public function info(): array

The server’s summary of the log: length, first-entry, last-entry, last-generated-id, the number of groups. No arguments.

php
$events->info()['length'];              // 1204
$events->info()['last-generated-id'];   // '1755600000123-0'

groups()

php
public function groups(): array

Every consumer group on this stream as the server sees it: name, consumers, pending, last-delivered-id, lag.

php
foreach ($events->groups() as $group) {
  if ($group['pending'] > 1000) {
      $this->alert("group {$group['name']} is falling behind");
  }
}

lag is how many entries the group has not seen yet; together with pending these are two different kinds of falling behind: “not read” and “read but not acknowledged”.

The key as a whole

php
$events->expireKey(86400);   // a lifetime for the whole log
$events->keyTtl();           // 86400 | null
$events->deleteKey();        // delete the log together with its groups
$events->name();             // 'session:events'
$events->close();            // close the connection follow() opened

deleteKey() takes the entries and the groups with their bookkeeping. Entries have no lifetimes of their own — trimming bounds them.


Consumer groups

What they are for

Without a group every reader sees all entries and tracks its own position. A group changes both halves: an entry goes to one consumer of the group, and the server holds it in an unfinished list until it is acknowledged.

Hence the guarantee they are taken for: a worker took an entry and died — it is not lost. It is on record against that worker, visible in pending(), and another worker can claim it.

php
$events->ensureGroup('mailers');                          // once, at startup
$group = $events->group('mailers', consumer: 'worker-1');

ensureGroup()

php
public function ensureGroup(string $name, string $from = '0'): bool
Argument Type Default What it does
$name string the group name
$from string '0' where the group starts reading: '0' from the beginning, '$' only what arrives later

Returns true when the group was created and false when it already existed — the second is not an error. The stream is created too if needed, so a group can be declared at startup before the first entry is written.

Why a group is not created on the fly by consume()

Then a mistyped name would become a new empty group that silently receives nothing. Explicit creation leaves a typo one outcome only — a NOGROUP error naming it.

group()

php
public function group(string $name, string $consumer = 'default'): RedisStreamGroup

A handle on the group, acting as one consumer. Creates nothing and sends nothing.


RedisStreamGroup reference

Consuming

consume()

php
public function consume(int $count = 10, float $timeout = 0.0): array

Takes entries nobody in the group has been given yet.

Argument Type Default What it does
$count int 10 at most this many entries
$timeout float 0.0 seconds to wait; 0 waits indefinitely, a negative value does not wait at all

Returns list<StreamEntry>. Throws RedisCommandException with NOGROUP when the group does not exist.

Each entry goes to exactly one consumer of the group and enters that consumer’s unfinished list until it is acknowledged.

php
$group->consume(count: 10, timeout: 5);   // waits, on its own connection
$group->consume(timeout: -1);             // look and return, on a pooled connection

A negative timeout is the only mode that does not take a dedicated connection; it is for checks and one-off looks.

backlog()

php
public function backlog(int $count = 10): array

Returns what this consumer has already taken but not acknowledged.

Returns list<StreamEntry>, without waiting.

This is the first thing a restarted worker should do: entries it took before the restart are on record against it and will not arrive through consume() again. Reading them back is how the work resumes instead of waiting for someone to claim them on idle.

main/Daemons/MailWorker.php
$group = $events->group('mailers', consumer: $this->workerName());

// 1. finish your own first
foreach ($group->backlog() as $entry) {
  $this->handle($entry);
  $group->ack($entry);
}

// 2. and only then take new work
while (!$this->stopping) {
  foreach ($group->consume(count: 10, timeout: 5) as $entry) {
      $this->handle($entry);
      $group->ack($entry);
  }
}

$group->close();

ack()

php
public function ack(StreamEntry|string ...$entries): int

Marks entries as handled, removing them from the unfinished list.

Argument Type Default What it does
...$entries StreamEntry|string entries or their identifiers

Returns how many were unfinished and are now acknowledged. With no arguments, 0.

php
$group->ack($entry);              // the object
$group->ack($entry->id);          // or the identifier
$group->ack(...$entries);         // or a batch

Acknowledging does not delete the entry from the log

ack() closes the books inside the group: the entry stops counting as unfinished. The entry itself stays in the stream and is still visible to range() — it is a log, after all. Bounding the growth is a separate job, done by trimming.

The package will not acknowledge automatically on delivery: that would destroy the single guarantee a group exists for — that an entry outlives the worker that took it and never finished.

Recovery

pending()

php
public function pending(int $count = 100, ?string $consumer = null): array

Entries the group delivered and nobody acknowledged.

Argument Type Default What it does
$count int 100 at most this many
$consumer ?string null limit to one consumer; null covers the group

Returns list<PendingEntry>:

php
foreach ($group->pending() as $stuck) {
  $stuck->id;           // '1755600000123-0'
  $stuck->consumer;     // 'worker-1'
  $stuck->idleMs;       // 61240 — milliseconds since it was delivered
  $stuck->deliveries;   // 3 — how many times it has been delivered
}

The two numbers answer different questions. A large idleMs with a single delivery means the consumer died holding it. A climbing deliveries means the entry breaks whoever takes it.

What to do about the second — retry, alert, bury — is the application’s decision: only the author of the work knows what a retry costs.

pendingCount()

php
public function pendingCount(): int

How many entries the group has delivered without getting an acknowledgement. No arguments.

claimStale()

php
public function claimStale(int $idle, int $count = 10, string $from = '0-0'): array

Takes over entries that have been idle too long in someone else’s hands.

Argument Type Default What it does
$idle int how many milliseconds an entry must have been idle to be claimable
$count int 10 at most this many
$from string '0-0' where to start scanning the unfinished list

Returns list<StreamEntry> — the entries now claimed, ready to be handled.

php
foreach ($group->claimStale(idle: 60_000) as $entry) {
  $this->handle($entry);
  $group->ack($entry);
}

A repeated call will not return the same entries

Claiming resets the idle clock, so the next call with the same threshold no longer sees them — the loop converges instead of spinning in place. The delivery count does grow, which is how you see that an entry has come round again.

Pick the threshold from the longest honest piece of work: if sending an email takes up to thirty seconds, idle: 60_000 will not steal work from a living worker.

consumers()

php
public function consumers(): array

Every consumer of the group as the server sees it: name, pending, idle. No arguments.

php
foreach ($group->consumers() as $consumer) {
  if ($consumer['idle'] > 300_000 && $consumer['pending'] > 0) {
      $this->alert("{$consumer['name']} has been silent for five minutes holding work");
  }
}

destroy()

php
public function destroy(): bool

Removes the group with its bookkeeping and its consumers. The log’s entries stay.

Utility

php
$group->as('worker-2');    // the same group, as another consumer
$group->name();            // 'mailers'
$group->consumer();        // 'worker-1'
$group->close();           // close the connection a blocking consume() opened

Consumer names must be unique

The server keeps the unfinished list per consumer. Two workers sharing a name share one list and will take each other’s work — including whatever the first is handling right now. Name them after something stable and distinct: the worker slot, the host and pid.

Mapping to Redis commands

Method Command
add() XADD, with cap XADD ... MAXLEN
trim() / trimBefore() XTRIM MAXLEN / XTRIM MINID
delete() / count() XDEL / XLEN
range() / reverse() XRANGE / XREVRANGE
after() / follow() XREAD / XREAD BLOCK
info() / groups() / consumers() XINFO STREAM / GROUPS / CONSUMERS
ensureGroup() / destroy() XGROUP CREATE / XGROUP DESTROY
consume() / backlog() XREADGROUP with > / with 0
ack() XACK
pending() / pendingCount() XPENDING
claimStale() XAUTOCLAIM

What is not here — XCLAIM one by one, XSETID, XGROUP CREATECONSUMER — is available through raw() together with name().

Next

  • Lists — a queue when history is not needed
  • Hashes — an object’s state alongside a log of events
  • Connection pool — why blocking reads stay away from the pool