A Redis stream does not automatically track a separate cursor for every connection. Ordinary XREAD uses a position supplied by the application. A consumer group instead owns a server-side last-delivered position plus pending delivery records. Confusing those two forms of progress can make a restart skip unfinished work or replay effects already completed.

The group advances on delivery

XGROUP CREATE with 0-0 starts before retained history; $ starts after the current end. Each group maintains independent delivery state over the same stream records. Billing can have delivered two entries while analytics has delivered only one. Within a group, named consumers share new work rather than each receiving every entry.

For ordinary XREADGROUP without CLAIM or NOACK, > requests entries beyond the group’s delivery position and adds deliveries to its Pending Entries List, or PEL. That position advances before processing and acknowledgement. XACK removes specified pending IDs from one group, not stream records, and does not advance the group’s last-delivered ID.

Acknowledgements can arrive out of order. Finishing the second entry says nothing about whether the first finished. The group can have reached the end of the stream while older deliveries remain pending. Do not reconstruct successful processing from the largest delivered or acknowledged ID without an application contract that actually guarantees contiguous completion.

Pending history belongs to a consumer

Using 0-0 with XREADGROUP requests this named consumer’s pending history, not an unrestricted replay of the stream. A newly named worker sees none of another worker’s pending entries. Restarting under a different name and reading only > therefore does not recover abandoned deliveries. It can make the service look active while older work never resumes.

Recovery requires reading the original consumer’s pending history or explicitly transferring eligible work, for example with XAUTOCLAIM. Its idle threshold must account for legitimate processing time. Redis 8.4+ supports optional XREADGROUP CLAIM; this exercise does not enable it. Ownership transfer cannot prevent a late worker from repeating an external action, so processing still needs an appropriate deduplication policy.

Use XINFO GROUPS to compare last-delivered-id, pending, and lag. Lag estimates entries awaiting delivery from the group’s current position, not unfinished business actions. It can be unavailable after certain position changes or deletions; a null metric is not zero. A group with zero lag and nonzero pending work has reached the delivery end but still has outstanding acknowledgements.

When it breaks

XGROUP SETID is a deliberate change to the group’s delivery position, not a harmless acknowledgement repair. Rewinding after completed work makes retained, previously acknowledged entries eligible for new delivery again. Moving forward can skip unread entries. It does not automatically empty existing PEL records, and neither direction changes another group’s position.

Plan a repositioning operation with workers coordinated and business effects understood. Replaying into a separate group isolates delivery bookkeeping, but not external side effects if it calls the same handler. An acknowledgement is not a permanent tombstone that forbids all future reads of that record. Avoid using a rewind as a substitute for diagnosing the pending entries.

Independent group positions do not provide independent retention. Deleting or trimming a shared payload affects every group that might need it. Default KEEPREF trimming may leave pending IDs without data. Durability across server failure is also separate from the cursor mechanism. A recorded delivery position is neither proof that the payload survives nor proof that its business work completed.

Exact fixture states show independent group positions, out-of-order ACKs, consumer-owned pending entries, zero lag with unfinished acknowledgements and SETID retention boundaries.
Successive fixture states separate delivered positions from pending ownership and ACKs. No current Redis runtime or production recovery claim is made.

Check it quickly

The article reports macOS, Python 3.14.4, Redis 8.10.1 and redis-py 8.1.0; no matching run log was located among reviewed evidence, and nothing was rerun here. Require POSIX, a C compiler, make, curl, tar and Python 3.10+. Keep assertions enabled: -O or PYTHONOPTIMIZE skips checks and required calls.

redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0

Run with "$redis_lab/venv/bin/python" in the same shell. Check setup succeeds; downloads need network. Redis uses a private /tmp Unix socket, with TCP, scheduled snapshots and AOF disabled. Cleanup attempts to stop its process and remove runtime files; the build/venv remains. Printed summaries precede cleanup. Default Redis is not contacted.

from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry


@contextmanager
def instance():
    with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
        endpoint = str(Path(directory) / "redis.sock")
        process = subprocess.Popen([
            os.environ["REDIS_SERVER"], "--port", "0",
            "--unixsocket", endpoint, "--unixsocketperm", "700",
            "--save", "", "--appendonly", "no", "--dir", directory,
            "--maxmemory-policy", "noeviction",
        ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
                             socket_timeout=2, retry=Retry(NoBackoff(), 0))
        try:
            deadline = time.monotonic() + 5
            while True:
                assert process.poll() is None, "Redis exited during startup"
                try:
                    if client.ping():
                        break
                except redis.ConnectionError:
                    if time.monotonic() >= deadline:
                        raise
                    time.sleep(0.02)
            yield client
        finally:
            client.close()
            if process.poll() is None:
                process.terminate()
            try:
                _, errors = process.communicate(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
                process.communicate()
                raise
            assert process.returncode == 0, errors.decode()


def groups(client):
    return {group["name"]: group for group in client.xinfo_groups("events")}


with instance() as client:
    for entry_id in ("1-0", "2-0", "3-0"):
        client.xadd("events", {"event": entry_id}, id=entry_id)
    for name in ("billing", "analytics"):
        client.xgroup_create("events", name, id="0-0")
    billing = client.xreadgroup("billing", "b1", {"events": ">"}, count=2)[0][1]
    analytics = client.xreadgroup("analytics", "a1", {"events": ">"}, count=1)[0][1]
    assert [entry[0] for entry in billing] == [b"1-0", b"2-0"]
    assert [entry[0] for entry in analytics] == [b"1-0"]
    state = groups(client)
    assert state[b"billing"]["last-delivered-id"] == b"2-0"
    assert state[b"analytics"]["last-delivered-id"] == b"1-0"
    assert state[b"billing"]["pending"] == 2 and state[b"analytics"]["pending"] == 1
    assert state[b"billing"]["lag"] == 1 and state[b"analytics"]["lag"] == 2
    print("two groups read overlapping records but retain independent positions and PELs")

    assert client.xack("events", "billing", "2-0") == 1
    state = groups(client)
    assert state[b"billing"]["last-delivered-id"] == b"2-0"
    assert state[b"billing"]["pending"] == 1 and state[b"analytics"]["pending"] == 1
    assert client.xreadgroup("billing", "b2", {"events": "0-0"}) == [[b"events", []]]
    own = client.xreadgroup("billing", "b1", {"events": "0-0"})[0][1]
    assert [entry[0] for entry in own] == [b"1-0"]
    next_entry = client.xreadgroup("billing", "b2", {"events": ">"})[0][1]
    assert [entry[0] for entry in next_entry] == [b"3-0"]
    assert groups(client)[b"billing"]["lag"] == 0
    assert groups(client)[b"billing"]["pending"] == 2
    assert client.xack("events", "billing", "1-0", "3-0") == 2
    assert groups(client)[b"billing"]["pending"] == 0
    assert client.xpending("events", "analytics")["pending"] == 1
    assert client.xlen("events") == 3
    print("ACK affects only one group's PEL; a new consumer sees no other owner's pending history")

    client.xgroup_setid("events", "billing", "0-0")
    replay = client.xreadgroup("billing", "b3", {"events": ">"}, count=1)[0][1]
    assert [entry[0] for entry in replay] == [b"1-0"]
    assert groups(client)[b"analytics"]["last-delivered-id"] == b"1-0"
    assert client.xack("events", "billing", "1-0") == 1
    client.xgroup_setid("events", "analytics", "$")
    state = groups(client)
    assert state[b"analytics"]["last-delivered-id"] == b"3-0"
    assert state[b"analytics"]["pending"] == 1
    assert state[b"analytics"]["lag"] == 0
    assert client.xreadgroup("analytics", "a1", {"events": ">"}) == []
    own = client.xreadgroup("analytics", "a1", {"events": "0-0"})[0][1]
    assert [entry[0] for entry in own] == [b"1-0"]
    assert client.xack("events", "analytics", "1-0") == 1
    print("SETID rewinds acknowledged data for replay; moving forward skips unread data but keeps PEL")

Billing reads two entries and analytics reads one, producing distinct delivery positions and pending counts. Acknowledging billing’s second entry changes only its PEL. Consumer b2 cannot read b1’s pending history, but it can receive the next new entry. After all new billing entries are delivered, lag is zero while two deliveries still await acknowledgement.

Once billing work is acknowledged, explicitly rewinding its position lets b3 receive 1-0 again. Analytics remains independent. Moving analytics to $ skips its unread entries but preserves the pending 1-0 record, which its original consumer can still read and acknowledge.

All state comparisons use actual Redis replies; no timing assumptions or concurrent workers are needed. The experiment verifies group isolation, delivery positions, pending ownership, and deliberate repositioning. It does not exercise claiming, trimming, deletion, crash detection, persistence or transaction-safe business processing.

Sources

  • Redis XREAD, positions supplied by independent readers, checked 2026-09-08.
  • Redis XREADGROUP, group delivery positions and consumer pending history, checked 2026-09-08.
  • Redis XACK, group-specific pending acknowledgement, checked 2026-09-08.
  • Redis XINFO GROUPS, last-delivered ID, pending count, and lag limitations, checked 2026-09-08.
  • Redis XGROUP SETID, deliberate repositioning of a group, checked 2026-09-08.
  • Redis XAUTOCLAIM, explicit recovery of eligible pending entries, checked 2026-09-08.
  • Redis XTRIM, payload retention and consumer-group references, checked 2026-09-08.
  • Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.
  • Python assert, optimization removes assertion evaluation, checked 2026-10-18.