Skip to content

Commit 518537f

Browse files
committed
Merge remote-tracking branch 'refs/remotes/origin/dev/alpha'
2 parents 23cfe33 + 9272e54 commit 518537f

7 files changed

Lines changed: 440 additions & 25 deletions

File tree

CONTRIBUTING.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ activate a virtual environment, then install the complete development toolchain:
77
python -m pip install -e '.[dev]'
88
```
99

10+
To run only the test suite (including its optional FastAPI, Redis, PostgreSQL,
11+
and async test dependencies), install the test requirements instead:
12+
13+
```bash
14+
python -m pip install -r requirements-test.txt
15+
python -m pytest tests
16+
```
17+
18+
In PyCharm, select the project's virtual environment as the interpreter and
19+
install `requirements-test.txt` before using the IDE's pytest runner. Running
20+
pytest from an environment containing only the core package is unsupported:
21+
the strict pytest configuration deliberately requires `pytest-asyncio`, and
22+
adapter tests import their optional frameworks during collection.
23+
1024
Run the same quality workflow used by every CI Python version:
1125

1226
```bash

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). |

requirements-test.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Keep test dependencies centralized in pyproject.toml while providing IDEs with
2+
# a conventional requirements file to discover and install.
3+
-e .[test]

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

tests/test_project_metadata.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ def test_dev_extra_includes_docs_build_tooling() -> None:
1313
assert any(dependency.startswith("mkdocs") for dependency in dev_dependencies)
1414

1515

16+
def test_test_requirements_install_the_test_extra() -> None:
17+
"""IDE-friendly requirements must delegate to canonical project metadata."""
18+
requirements = Path("requirements-test.txt").read_text().splitlines()
19+
20+
entries = [line.strip() for line in requirements if not line.startswith("#")]
21+
22+
assert entries == ["-e .[test]"]
23+
24+
1625
def test_environment_configuration_converts_declared_types(monkeypatch) -> None:
1726
monkeypatch.setenv("EVENTFUL_ENABLE_PRIORITIES", "no")
1827
monkeypatch.setenv("EVENTFUL_FILE_MAX_SIZE", "2048")

0 commit comments

Comments
 (0)