Skip to content

Commit 9272e54

Browse files
authored
fix: Validate Redis operational failure boundaries and document at-most-once limits
2 parents a0d7a55 + 2d7b83f commit 9272e54

4 files changed

Lines changed: 414 additions & 25 deletions

File tree

docs/redis.md

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,56 @@ lazily when explicit coordination is unnecessary.
8181

8282
## Reconnection and cancellation
8383

84-
The transport relies on redis-py's connection retry and Pub/Sub resubscription
85-
behavior. Redis client options may be passed as keyword arguments to
86-
`RedisTransport` and are forwarded to `redis.asyncio.Redis.from_url`. Terminal
87-
client errors become `TransportError` with the original exception retained as the
88-
cause. Task cancellation remains `asyncio.CancelledError` and triggers consumer
89-
cleanup.
84+
Eventful does **not** implement a retry queue or replay loop. It makes one public
85+
method call and relies only on retry behavior configured in redis-py:
86+
87+
- `publish()` calls redis-py's `publish()` once. A reconnect/retry completed by the
88+
client can make that call succeed; otherwise Eventful raises `TransportError`.
89+
Eventful never resends after the call returns or raises, because after a lost
90+
response it cannot know whether Redis received the publication.
91+
- `subscribe()` calls redis-py's `subscribe()` once. An unrecovered connection
92+
failure is a `TransportError`; cancellation remains `asyncio.CancelledError` and
93+
closes the incomplete Pub/Sub resource.
94+
- `consume()` lets redis-py reconnect and restore its current Pub/Sub subscriptions
95+
while polling. An unrecovered read failure is a `TransportError` and permanently
96+
closes that consumer. Start a new consumer to try again.
97+
- `close()` does not retry failed unsubscribe or close operations. It attempts all
98+
consumer closes before surfacing a shutdown `TransportError`, and repeated calls
99+
remain safe.
100+
101+
Redis client options may be passed as keyword arguments to `RedisTransport` and
102+
are forwarded to `redis.asyncio.Redis.from_url`. Original client exceptions are
103+
retained as causes. Task cancellation is never translated to `TransportError`.
90104

91105
Automatic reconnection does not change the at-most-once delivery model: messages
92-
sent while disconnected may be lost.
106+
sent while disconnected may be lost. A successful `publish()` means Redis reported
107+
the number of live subscribers; it does not mean a consumer processed, persisted,
108+
or can replay the event. A clean connection reset can be recovered internally by
109+
redis-py, including resubscription. In the validated default configuration, a hard
110+
server restart surfaced the broken read as `TransportError`; callers had to create
111+
a fresh consumer after the server returned. Either outcome leaves an unavoidable
112+
delivery gap.
113+
114+
## Validated operational limits
115+
116+
Service-backed scenarios exercise forced publisher and subscriber connection
117+
loss, reconnection after the service becomes available again, cancellation during
118+
reconnection, duplicate-subscription cleanup, malformed broker messages,
119+
concurrent bursts, and bounded deterministic shutdown. These checks establish
120+
lifecycle and error behavior, not a durability guarantee:
121+
122+
- Connection recovery is bounded by redis-py's configured retry policy and the
123+
caller's own timeout or cancellation.
124+
- No event count is asserted across a disconnect or restart boundary. Only events
125+
published after subscription readiness is re-established are expected. A hard
126+
restart may terminate the old consumer before redis-py's reconnect path runs.
127+
- Concurrent publishing is safe when callers bound concurrency to the configured
128+
redis-py pool. A task fan-out beyond `max_connections` is surfaced as a publish
129+
`TransportError`; Eventful supplies no global ordering or unbounded buffering.
130+
- Malformed data terminates only the affected consumer; it is not skipped or sent
131+
to a dead-letter channel.
132+
- Shutdown is deterministic for responsive Redis connections. A stalled network
133+
operation still needs an application-level deadline such as `asyncio.timeout()`.
93134

94135
## Serialization and validation
95136

docs/work-register.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ All intentional incompleteness must use an annotation listed in `docs/annotation
55
| Module | Status | Deferred work |
66
| --- | --- | --- |
77
| `eventful.adapters` | provisional ASGI lifecycle | Validate multi-worker ownership and framework-version compatibility (Issue: Forebase/Eventful#1). |
8-
| `eventful.transports.redis` | provisional Pub/Sub | Validate operational reconnect and load behavior; Redis Streams durability remains deferred (Issue: Forebase/Eventful#2). |
8+
| `eventful.transports.redis` | provisional, service-validated Pub/Sub | Connection loss/restart gaps, cancellation cleanup, malformed input, concurrent publishing, and shutdown limits validated; delivery remains live at-most-once with no Eventful retry/replay or durability. Redis Streams remains deferred (Forebase/Eventful#2). |
99
| `eventful.persistence.postgres_persistence` | provisional durable store | Validate migration upgrades, retention, replication, and operational load behavior (Issue: Forebase/Eventful#3). |
1010
| `eventful.contracts` | provisional, reference-validated | Validate async lifecycle, delivery, and durability semantics against real Redis/PostgreSQL integrations (Issue: Forebase/Eventful#4). |

src/eventful/transports/redis.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
Events publish to channels derived from their event type. Redis Pub/Sub provides
44
live, at-most-once fan-out: it has no replay, acknowledgement, or durability. The
55
transport owns clients it creates, while injected clients remain caller-owned.
6-
redis-py's connection retry and Pub/Sub resubscription behavior handle transient
7-
reconnections; terminal client errors propagate to the consumer.
6+
redis-py's configured connection retry and Pub/Sub resubscription behavior may
7+
handle transient reconnects. Eventful adds no retry loop: failures left by the
8+
client are surfaced, and publications are never replayed.
89
"""
910

1011
from __future__ import annotations
1112

1213
import asyncio
14+
import sys
1315
from collections.abc import AsyncIterator
1416
from typing import Any
1517

@@ -29,7 +31,7 @@ def __init__(self, transport: RedisTransport) -> None:
2931
self._closed = False
3032

3133
async def publish(self, event: Event) -> None:
32-
"""Publish one event to its prefixed event-type channel."""
34+
"""Publish once, surfacing any failure not retried by redis-py."""
3335
if self._closed:
3436
raise TransportError("Redis publisher is closed")
3537
self._transport._ensure_open()
@@ -72,11 +74,11 @@ def topic(self) -> str:
7274
return self._topic
7375

7476
async def subscribe(self) -> None:
75-
"""Eagerly establish the subscription when readiness must be explicit."""
77+
"""Subscribe once, surfacing any failure not recovered by redis-py."""
7678
await self._subscribe()
7779

7880
async def consume(self) -> AsyncIterator[Event]:
79-
"""Yield live events sequentially until cancellation, failure, or close."""
81+
"""Yield live events, surfacing unrecovered reads and invalid payloads."""
8082
async with self._lifecycle_lock:
8183
if self._closed:
8284
raise TransportError("Redis consumer is closed")
@@ -121,7 +123,16 @@ async def consume(self) -> AsyncIterator[Event]:
121123
finally:
122124
async with self._lifecycle_lock:
123125
self._consuming = False
124-
await self.close()
126+
active_failure = sys.exception()
127+
try:
128+
await self.close()
129+
except TransportError:
130+
# Cleanup must not replace cancellation, malformed-message, or
131+
# connection errors already escaping the iterator. GeneratorExit
132+
# denotes an explicit/normal iterator close, so its cleanup error
133+
# remains observable to the caller.
134+
if active_failure is None or isinstance(active_failure, GeneratorExit):
135+
raise
125136

126137
async def close(self) -> None:
127138
"""Unsubscribe and close the Pub/Sub resource idempotently."""
@@ -131,13 +142,20 @@ async def close(self) -> None:
131142
self._closed = True
132143
pubsub, self._pubsub = self._pubsub, None
133144
if pubsub is not None:
145+
failure: BaseException | None = None
134146
try:
135147
await pubsub.unsubscribe(self._transport.channel(self.topic))
148+
except Exception as exc:
149+
failure = exc
150+
try:
136151
await pubsub.aclose()
137152
except Exception as exc:
153+
failure = failure or exc
154+
if failure is not None:
155+
self._transport._discard_consumer(self)
138156
raise TransportError(
139157
f"Redis consumer close failed for topic {self.topic!r}"
140-
) from exc
158+
) from failure
141159
self._transport._discard_consumer(self)
142160

143161
async def _subscribe(self) -> Any:
@@ -151,8 +169,21 @@ async def _subscribe(self) -> Any:
151169
pubsub = self._transport.client.pubsub()
152170
try:
153171
await pubsub.subscribe(self._transport.channel(self.topic))
154-
except Exception as exc:
155-
await pubsub.aclose()
172+
except BaseException as exc:
173+
# Cancellation is deliberately included: a connection attempt may
174+
# be cancelled while redis-py is reconnecting, but its Pub/Sub
175+
# object must not retain a duplicate server-side subscription.
176+
try:
177+
await asyncio.shield(pubsub.aclose())
178+
except BaseException as cleanup_exc:
179+
# In particular, task cancellation must remain cancellation
180+
# even when a broken connection also makes cleanup fail.
181+
exc.add_note(
182+
"Redis subscription cleanup also failed: "
183+
f"{cleanup_exc!r}"
184+
)
185+
if isinstance(exc, asyncio.CancelledError):
186+
raise exc
156187
raise TransportError(
157188
f"Redis subscribe failed for topic {self.topic!r}"
158189
) from exc

0 commit comments

Comments
 (0)