Chapter 16 · Pub/Sub, Keyspace Notifications, and Messaging Tradeoffs
At-Most-Once Delivery, Disconnect Loss, Slow Subscribers, and Why Pub/Sub Is Not a Durable Queue
Prove Redis Pub/Sub at-most-once delivery with an intentional disconnect, bound slow-subscriber behavior, and show why no subscription cursor, acknowledgment, or replay state exists.
Learning outcomes
AtlasMart now understands channels, but a dangerous assumption remains: “if Redis accepted the publication, every notification worker will eventually process it.” This lesson breaks that assumption with a controlled disconnect and shows how a slow subscriber can accumulate buffers even though the publisher has no acknowledgment of downstream business processing.
Prove at-most-once disconnect loss with a deterministic subscriber/publisher sequence.
Separate Redis publication delivery from application callback completion and external side effects.
Inspect Pub/Sub client state/output-buffer indicators without forcing unsafe buffer-limit changes.
Bound application-side subscriber queues and explain why slow consumers require backpressure or a different messaging primitive.
Compare the same disconnected interval with a Redis Stream entry that remains available for replay.
All Chapter 16 mandatory labs reuse the disposable Chapter 01
environment: Redis Open Source 8.10.1 from pinned
Docker image redis:8.10.1, container
atlasmart-redis-ch01, standalone topology,
endpoint 127.0.0.1:6379, TLS disabled only
because traffic is loopback-local, default ACL user disabled,
named academy-admin and
atlasmart-app users, logical database 0, AOF with
appendfsync everysec plus RDB snapshots,
persistent /data, and no explicit
maxmemory/eviction policy. Python examples target
redis-py 8.1.0. The Chapter 01 application ACL
intentionally does not assume Pub/Sub command permissions, so
Pub/Sub/configuration demonstrations use
academy-admin until Lesson 5 creates a temporary
least-privilege Chapter 16 user. Fixtures stay under
atlasmart:ch16:.
1. “Sent” is not “processed”
PUBLISH returning a positive count means Redis sent
a message toward currently matching subscribers. It does not
mean their callback succeeded, a downstream API completed, or
the notification was durably recorded. There is no Pub/Sub
acknowledgment command. If a process crashes after Redis pushes
a message but before business processing finishes, Redis will
not redeliver it.
Never make a non-repeatable business transition depend solely on receiving a Pub/Sub message. Write durable state first; use Pub/Sub only to reduce time-to-observe that state.
2. Deterministic disconnect-loss experiment
The following redis-py script subscribes, receives one live message, closes the subscription, publishes two messages during the gap, then creates a fresh subscription and publishes one more. The expected observation is sequence 1 and 4 only. The exact subscriber confirmation frames vary by redis-py/RESP response mode, so the code drains confirmations instead of treating their shape as business data.
import time, redisr = redis.Redis(host="127.0.0.1", port=6379, username="academy-admin", password="AtlasMart-Admin-Lab-Only-2026", protocol=3, decode_responses=True)channel = "atlasmart:ch16:l2:disconnect"def wait_message(ps, timeout=1.5): end = time.monotonic() + timeout while time.monotonic() < end: m = ps.get_message(ignore_subscribe_messages=True, timeout=0.1) if m is not None: return m return Noneps = r.pubsub()ps.subscribe(channel)time.sleep(0.05)r.publish(channel, "seq=1")print("online", wait_message(ps))ps.close()print("publish_while_offline", r.publish(channel, "seq=2"), r.publish(channel, "seq=3"))ps = r.pubsub()ps.subscribe(channel)time.sleep(0.05)print("unexpected_replay", wait_message(ps, 0.30)) # expected Noner.publish(channel, "seq=4")print("future", wait_message(ps))ps.close()# Expected: seq=1 and seq=4 are visible; seq=2/3 are not replayed.
3. Live subscriber count explains the loss window
During the disconnected interval,
PUBSUB NUMSUB should report zero exact subscribers
for the test channel. A subsequent PUBLISH can
therefore return zero. That is concrete evidence that no active
connection was present; it is not a durable delivery ledger and
cannot tell you which historical messages a former subscriber
missed.
PUBSUB NUMSUB atlasmart:ch16:l2:disconnectPUBLISH atlasmart:ch16:l2:disconnect probe-with-no-subscriber# If no exact subscriber is live, NUMSUB and PUBLISH should report zero recipients in this standalone fixture.
4. Slow subscribers create queueing and buffer pressure
Redis pushes messages asynchronously; it does not wait for application work after each push. If a subscriber reads the socket slowly, data can accumulate in socket/server output buffers until configured Pub/Sub output-buffer limits or network failure disconnect the client. If the process reads Redis promptly but queues work internally faster than workers consume it, the application can instead exhaust its own memory. Those are different pressure points and should be measured separately.
docker exec -e REDISCLI_AUTH=AtlasMart-Admin-Lab-Only-2026 atlasmart-redis-ch01 redis-cli --user academy-admin PINGdocker exec -e REDISCLI_AUTH=AtlasMart-Admin-Lab-Only-2026 atlasmart-redis-ch01 redis-cli --user academy-admin INFO serverdocker exec -e REDISCLI_AUTH=AtlasMart-Admin-Lab-Only-2026 atlasmart-redis-ch01 redis-cli --user academy-admin INFO clientsdocker exec -e REDISCLI_AUTH=AtlasMart-Admin-Lab-Only-2026 atlasmart-redis-ch01 redis-cli --user academy-admin CONFIG GET appendonly appendfsync maxmemory maxmemory-policy notify-keyspace-events client-output-buffer-limitdocker exec -e REDISCLI_AUTH=AtlasMart-Admin-Lab-Only-2026 atlasmart-redis-ch01 redis-cli --user academy-admin ACL WHOAMI
This lesson observes
client-output-buffer-limit with CONFIG GET but
does not reduce it or flood the server until forced
disconnect. Production thresholds are configuration/capacity
decisions, not a classroom constant.
5. Bounded slow-consumer harness
This fixture creates one subscriber thread that intentionally sleeps after each message while a publisher sends 60 small messages. The application keeps only a bounded deque of the most recent 20 observations. It demonstrates that “slow consumer” is an application design condition without trying to exhaust Redis or the host. Record processed count and elapsed time on your machine; do not compare those values across environments as a Redis benchmark.
import collections, threading, time, redisr = redis.Redis(host="127.0.0.1", port=6379, username="academy-admin", password="AtlasMart-Admin-Lab-Only-2026", decode_responses=True)channel = "atlasmart:ch16:l2:slow"recent = collections.deque(maxlen=20)done = threading.Event()def consume(): ps = r.pubsub() ps.subscribe(channel) processed = 0 deadline = time.monotonic() + 5 while processed < 60 and time.monotonic() < deadline: m = ps.get_message(ignore_subscribe_messages=True, timeout=0.1) if not m: continue recent.append(m["data"]) processed += 1 time.sleep(0.01) # intentionally slower callback print("processed", processed, "recent_buffer", len(recent)) ps.close(); done.set()t = threading.Thread(target=consume, daemon=True); t.start()time.sleep(0.10)for i in range(60): r.publish(channel, f"m{i}")done.wait(6)# Bounded local buffer prevents the demonstration itself from becoming an unbounded-memory pattern.
6. CLIENT LIST is evidence, not a universal field contract
Name subscriber connections when possible and inspect
CLIENT LIST TYPE pubsub. Current Redis exposes
output-buffer/client-state fields whose exact names and
interpretation can evolve; record the raw row with server
version. A transient zero buffer on loopback can simply mean the
kernel/application drained data quickly. Use sustained
representative load to size production limits.
CLIENT LIST TYPE pubsubCONFIG GET client-output-buffer-limit# Capture client name/id, idle time, output-buffer indicators, and server version together.# Do not treat one loopback snapshot as a capacity test.
7. Same outage interval, different Stream behavior
A Redis Stream is a stored data structure. If AtlasMart appends entries while no consumer is running, the entries remain addressable until retention/deletion removes them. A consumer can replay by ID, and consumer groups add pending/acknowledgment state. That is why Streams can support at-least-once processing patterns while Pub/Sub cannot.
XADD atlasmart:ch16:l2:stream '*' seq 2 payload offline-event-2XADD atlasmart:ch16:l2:stream '*' seq 3 payload offline-event-3XLEN atlasmart:ch16:l2:streamXRANGE atlasmart:ch16:l2:stream - +# These entries remain readable after the writer/reader disconnect window, unlike Pub/Sub publications.
8. Failure/misuse: infinite retries do not create durability
Reconnecting a Pub/Sub client aggressively cannot recover publications that occurred while it was absent. Infinite reconnect loops can also amplify outages with connection storms. Use bounded backoff/jitter and a reconciliation step: after reconnect, read authoritative state or replay a durable Stream/broker log from a known cursor.
| Failure | Pub/Sub-only outcome | Repair |
|---|---|---|
| Subscriber crash after push | message may be lost | durable state + idempotent reconciliation |
| Network disconnect | publications during gap lost | re-read state or replay durable log |
| Slow callback | buffers/lag grow, possible disconnect | bound queue, scale work, or durable consumer model |
| Publisher retry | duplicate application signals possible | idempotent payload/version handling |
9. Verification and cleanup
- The disconnect harness observed no replay after reconnect.
- Subscriber counts were interpreted as live state only.
- The slow-consumer fixture bounded its own application buffer and message count.
- No output-buffer limit was weakened and no host networking was manipulated.
- The Stream entries remained addressable after publication time.
UNLINK atlasmart:ch16:l2:stream
10. Production judgment
Use Pub/Sub when the subscriber can tolerate gaps and reconstruct current state. If each work item must eventually be handled, move the requirement to Streams or another broker with explicit persistence and consumer-state semantics. Under Sentinel or Cluster failover, assume subscribers can disconnect and miss publications during reconnect unless your specific managed/client topology proves otherwise. Track reconnects, subscriber callback latency, client/output buffers, dropped local work, and reconciliation success.
Check your understanding
- What does a positive PUBLISH return prove?
- Can reconnecting replay missed Pub/Sub messages?
- Why not lower client-output-buffer-limit for a classroom failure?
- What does a Stream add?
- What should happen after Pub/Sub reconnect?
Review the answers
Redis sent the publication toward currently matching subscribers; it does not prove application processing or durable completion.
No. Reconnection establishes a new live subscription only.
It changes server-wide behavior and can affect unrelated clients; bounded observation is safer.
Persisted entries and replay; consumer groups can also add pending ownership and acknowledgment state.
Reconcile from authoritative state or a replayable event source instead of trusting the live channel to fill the gap.
11. Summary and next step
The disconnect experiment turns at-most-once from a slogan into evidence: offline publications disappear. Slow subscribers introduce separate server-socket and application-queue pressure. Lesson 3 applies the same skepticism to keyspace notifications, which are generated automatically but still ride on the same lossy Pub/Sub mechanism.
Authoritative references
- Redis Open Source 8.10 release notes
- Redis Pub/Sub
- Redis Pub/Sub use case
- Redis Pub/Sub with redis-py
- PUBLISH
- SUBSCRIBE
- PSUBSCRIBE
- UNSUBSCRIBE
- PUNSUBSCRIBE
- SSUBSCRIBE
- SPUBLISH
- SUNSUBSCRIBE
- PUBSUB CHANNELS
- PUBSUB NUMSUB
- PUBSUB NUMPAT
- PUBSUB SHARDCHANNELS
- PUBSUB SHARDNUMSUB
- Redis keyspace notifications
- Redis subkey notifications
- CONFIG GET
- CONFIG SET
- CLIENT LIST
- CLIENT SETNAME
- Redis client output-buffer limits
- Redis Streams
- Redis streaming use case
- XADD
- XREADGROUP
- XPENDING
- XACK
- Redis ACLs
- ACL SETUSER
- ACL DRYRUN
- Redis Cluster specification
- Redis replication
- Redis Sentinel
- Redis persistence
- redis-py documentation
- redis-py 8.1.0 on PyPI
- Redis licenses