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. Current Redis also 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.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. The commands compile Redis core with libc and install its client in a temporary virtual environment, without optional modules or system-wide installation.
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 the Python block with "$redis_lab/venv/bin/python" in the same shell. It creates a private Unix socket under /tmp, disables TCP, scheduled snapshots, and append-only persistence, then stops only its own process and removes its runtime directory. Downloads require network access. No default local Redis is 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 implement crash detection, recovery scheduling, persistence testing, 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.