Skip to content

Commit a1d2973

Browse files
authored
Merge pull request #24 from modern-python/backlog-cleanup
Backlog cleanup
2 parents 8fe154c + 715e9f3 commit a1d2973

5 files changed

Lines changed: 152 additions & 7 deletions

File tree

docs/introduction/how-it-works.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,10 @@ The default ack policy is `NACK_ON_ERROR`: the timer is acknowledged on success,
6666
| `polling_interval` | `0.05` s | Base poll interval used when the queue has work or just transitioned from idle |
6767
| `max_polling_interval` | `5.0` s | Ceiling for the adaptive idle backoff — `polling_interval` doubles up to this value on consecutive empty polls. Worst-case delivery latency on a previously-idle queue is `max_polling_interval × 1.5` (with ±50% jitter) |
6868
| `max_concurrent` | `5` | Max handlers running concurrently per subscriber; also bounds fetch batch size |
69-
| `lease_ttl` | `30` s | How long a worker holds the lease before another worker may re-claim |
69+
| `lease_ttl` | `30` s | How long a worker holds the lease before another worker may re-claim. Set to ~3–5× the P99 handler runtime: lower values speed up recovery from worker death, higher values tolerate handler GC pauses and clock skew |
70+
71+
## Operational requirements
72+
73+
`faststream-redis-timers` is designed for a **single-primary Redis** (Sentinel-managed primary/replica setups are supported; Redis Cluster is not — see the broker constructor docstring).
74+
75+
Multiple brokers polling the same Redis derive timer due-times and lease deadlines from each broker's local wall clock. Keep all broker hosts NTP-synchronised: clock skew larger than `lease_ttl` between brokers can cause a clock-fast broker to re-lease a timer that another broker is still actively processing, producing duplicate delivery to handlers. The default `lease_ttl=30s` tolerates seconds of NTP drift; tune `lease_ttl` upward if your environment cannot guarantee sub-second sync.

docs/usage/testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Each `ScheduledTimer` has `topic`, `timer_id`, `activate_at`, `body`, `correlati
8989
- `publisher.fetch_redis_timers()` always returns `[]` in tests — timers are delivered instantly, so there are never any pending entries.
9090
- The inspection / bulk-cancel methods follow the same "nothing pending" contract: `broker.has_pending(...)` returns `False`, `broker.get_pending_timers(...)` returns `[]`, and `broker.cancel_all(...)` returns `0`. Use `TestTimersBroker.scheduled_timers` to assert *what was published*; the inspection API is for production introspection.
9191
- The fake producer uses the same envelope format as the real one, so all serialization paths are exercised.
92+
- `lease_ttl` and re-delivery are **not** simulated — handlers that exceed the configured TTL in production may be re-delivered to another worker, but tests will only invoke the handler once. Idempotency must be verified separately.
9293

9394
## pytest-asyncio configuration
9495

faststream_redis_timers/broker.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
from faststream._internal.context.repository import ContextRepo
3434

3535

36+
def _require_topic(topic: str) -> None:
37+
if not topic:
38+
msg = "topic must be a non-empty string"
39+
raise ValueError(msg)
40+
41+
3642
class TimersParamsStorage(DefaultLoggerStorage):
3743
__max_msg_id_ln = -1
3844
_max_channel_name = 7
@@ -176,15 +182,16 @@ async def ping(self, timeout: float | None = None) -> bool:
176182

177183
async def publish( # noqa: PLR0913
178184
self,
179-
message: "SendableMessage" = None,
180-
topic: str = "",
185+
message: "SendableMessage",
186+
topic: str,
181187
*,
182188
timer_id: str = "",
183189
activate_in: timedelta = timedelta(0),
184190
activate_at: datetime | None = None,
185191
correlation_id: str | None = None,
186192
headers: dict[str, typing.Any] | None = None,
187193
) -> str:
194+
_require_topic(topic)
188195
if not timer_id:
189196
timer_id = gen_cor_id()
190197
cmd = TimerPublishCommand(
@@ -201,12 +208,14 @@ async def publish( # noqa: PLR0913
201208

202209
async def cancel_timer(self, topic: str, timer_id: str) -> None:
203210
"""Cancel a pending timer. No-op if the timer has already fired or does not exist."""
211+
_require_topic(topic)
204212
full_topic = f"{self.config.broker_config.prefix}{topic}"
205213
producer = typing.cast("TimersProducer", self.config.broker_config.producer)
206214
await producer.cancel(full_topic, timer_id)
207215

208216
async def has_pending(self, topic: str, timer_id: str) -> bool:
209217
"""Return True if a timer with this ID is still pending on *topic*."""
218+
_require_topic(topic)
210219
client = self.config.broker_config.connection.client
211220
score = await client.zscore(self._topic_timeline_key(topic), timer_id)
212221
return score is not None
@@ -219,6 +228,7 @@ async def get_pending_timers(self, topic: str, before: datetime | None = None) -
219228
into the future, so they appear in the default (``before=None``) result but are excluded
220229
once *before* is set to the current time.
221230
"""
231+
_require_topic(topic)
222232
client = self.config.broker_config.connection.client
223233
score_max: str | float = before.timestamp() if before is not None else "+inf"
224234
raw_ids: list[bytes] | list[str] = await client.zrangebyscore(
@@ -233,6 +243,7 @@ async def cancel_all(self, topic: str) -> int:
233243
Handlers already executing for a leased timer continue to run to completion;
234244
their final commit is a no-op because the keys are gone.
235245
"""
246+
_require_topic(topic)
236247
client = self.config.broker_config.connection.client
237248
timeline_key = self._topic_timeline_key(topic)
238249
payloads_key = self._topic_payloads_key(topic)

faststream_redis_timers/subscriber/usecase.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ async def _consume(self, client: "RedisClient", *, start_signal: anyio.Event) ->
9999
while self.running:
100100
try:
101101
fetched = await self._get_msgs(client, tg, limiter)
102-
except Exception as e: # noqa: BLE001 # pragma: no cover
102+
except Exception as e: # noqa: BLE001
103103
self._log(log_level=logging.ERROR, message=f"Message fetch error: {e!r}", exc_info=e)
104104
error_attempt = min(error_attempt + 1, _BACKOFF_EXP_CAP)
105105
delay = min(2.0 ** (error_attempt - 1) * random.uniform(0.5, 1.5), 30.0) # noqa: S311
@@ -151,11 +151,23 @@ async def _claim_and_consume(
151151
limiter: anyio.CapacityLimiter,
152152
client: "RedisClient",
153153
) -> None:
154+
try:
155+
timer_id = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
156+
except UnicodeDecodeError as e:
157+
self._log(
158+
log_level=logging.WARNING,
159+
message=f"Dropping timer with non-UTF-8 id {raw_id!r}: {e!r}",
160+
)
161+
async with client.pipeline(transaction=True) as pipe:
162+
pipe.zrem(self._config.topic_timeline_key, raw_id)
163+
pipe.hdel(self._config.topic_payloads_key, raw_id)
164+
await pipe.execute()
165+
return
166+
154167
try:
155168
async with limiter:
156169
now = time.time()
157170
claim_score = now + lease_ttl
158-
timer_id = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
159171
raw_payload: bytes | None = await eval_cached(
160172
client,
161173
CLAIM_LUA,
@@ -181,7 +193,7 @@ async def _claim_and_consume(
181193
)
182194
self._log(log_level=logging.DEBUG, message=f"Timer {timer_id!r} delivered to handler")
183195
await self.consume(msg)
184-
except Exception as e: # noqa: BLE001 # pragma: no cover
196+
except Exception as e: # noqa: BLE001
185197
self._log(
186198
log_level=logging.ERROR,
187199
message=f"Timer {raw_id!r} consume error: {e!r}",

tests/test_unit.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import asyncio
2+
import logging
23
import warnings
34
from datetime import UTC, datetime
4-
from unittest.mock import AsyncMock, MagicMock
5+
from unittest.mock import AsyncMock, MagicMock, patch
56

7+
import anyio
68
import faststream.asgi.factories.asyncapi.try_it_out
79
import pytest
810
from faststream.exceptions import IncorrectState
@@ -300,6 +302,119 @@ async def handler(body: str) -> None: ...
300302
await asyncio.sleep(0.05)
301303

302304

305+
# --- topic must be non-empty (B8) ---
306+
307+
308+
async def test_publish_rejects_empty_topic() -> None:
309+
broker = TimersBroker(AsyncMock())
310+
with pytest.raises(ValueError, match="topic must be a non-empty string"):
311+
await broker.publish("msg", topic="")
312+
313+
314+
async def test_cancel_timer_rejects_empty_topic() -> None:
315+
broker = TimersBroker(AsyncMock())
316+
with pytest.raises(ValueError, match="topic must be a non-empty string"):
317+
await broker.cancel_timer("", "id")
318+
319+
320+
async def test_has_pending_rejects_empty_topic() -> None:
321+
broker = TimersBroker(AsyncMock())
322+
with pytest.raises(ValueError, match="topic must be a non-empty string"):
323+
await broker.has_pending("", "id")
324+
325+
326+
async def test_get_pending_timers_rejects_empty_topic() -> None:
327+
broker = TimersBroker(AsyncMock())
328+
with pytest.raises(ValueError, match="topic must be a non-empty string"):
329+
await broker.get_pending_timers("")
330+
331+
332+
async def test_cancel_all_rejects_empty_topic() -> None:
333+
broker = TimersBroker(AsyncMock())
334+
with pytest.raises(ValueError, match="topic must be a non-empty string"):
335+
await broker.cancel_all("")
336+
337+
338+
# --- error path log format (B6) ---
339+
340+
341+
async def test_consume_logs_get_msgs_error_with_repr() -> None:
342+
"""A Redis error during _get_msgs is logged at ERROR with `{e!r}` so the type and msg survive Sentry truncation."""
343+
client = AsyncMock()
344+
client.ping.return_value = True
345+
client.zrangebyscore.side_effect = ConnectionError("redis down")
346+
broker = TimersBroker(client, start_timeout=2.0)
347+
348+
@broker.subscriber("topic", polling_interval=0.05)
349+
async def handler(body: str) -> None: ...
350+
351+
sub = next(iter(broker._subscribers)) # noqa: SLF001
352+
log_calls: list[dict[str, object]] = []
353+
sub._log = MagicMock(side_effect=lambda **kwargs: log_calls.append(kwargs)) # noqa: SLF001 # ty: ignore[invalid-assignment]
354+
355+
async with broker:
356+
await asyncio.sleep(0.1)
357+
358+
error_logs = [c for c in log_calls if c.get("log_level") == logging.ERROR]
359+
assert error_logs, "expected at least one ERROR log"
360+
msg = error_logs[0]["message"]
361+
assert isinstance(msg, str)
362+
assert "Message fetch error" in msg
363+
assert "ConnectionError('redis down')" in msg
364+
365+
366+
async def test_claim_and_consume_logs_unhandled_error_with_repr() -> None:
367+
"""An unhandled error inside the limiter block is logged with `{raw_id!r}` and `{e!r}`."""
368+
client = AsyncMock()
369+
broker = TimersBroker(client)
370+
sub = broker.subscriber("topic")
371+
await broker.connect()
372+
373+
log_calls: list[dict[str, object]] = []
374+
sub._log = MagicMock(side_effect=lambda **kwargs: log_calls.append(kwargs)) # noqa: SLF001 # ty: ignore[invalid-assignment]
375+
376+
limiter = anyio.CapacityLimiter(1)
377+
raw_id = b"timer-1"
378+
379+
with patch(
380+
"faststream_redis_timers.subscriber.usecase.eval_cached",
381+
new=AsyncMock(side_effect=RuntimeError("boom")),
382+
):
383+
await sub._claim_and_consume(raw_id, 30, limiter, client) # noqa: SLF001
384+
385+
error_logs = [c for c in log_calls if c.get("log_level") == logging.ERROR]
386+
assert error_logs
387+
msg = error_logs[0]["message"]
388+
assert isinstance(msg, str)
389+
assert "b'timer-1'" in msg
390+
assert "RuntimeError('boom')" in msg
391+
392+
393+
# --- non-UTF-8 timer id recovery (B2) ---
394+
395+
396+
async def test_claim_and_consume_drops_non_utf8_id() -> None:
397+
"""A non-UTF-8 raw_id is removed from both keys so polls recover instead of looping forever."""
398+
client = AsyncMock()
399+
pipe = MagicMock()
400+
pipe.__aenter__ = AsyncMock(return_value=pipe)
401+
pipe.__aexit__ = AsyncMock(return_value=None)
402+
pipe.execute = AsyncMock(return_value=[1, 1])
403+
client.pipeline = MagicMock(return_value=pipe)
404+
405+
broker = TimersBroker(client)
406+
sub = broker.subscriber("topic")
407+
await broker.connect()
408+
bad_id = b"\xff\xfe-broken"
409+
limiter = anyio.CapacityLimiter(1)
410+
411+
await sub._claim_and_consume(bad_id, 30, limiter, client) # noqa: SLF001
412+
413+
pipe.zrem.assert_called_once_with(sub._config.topic_timeline_key, bad_id) # noqa: SLF001
414+
pipe.hdel.assert_called_once_with(sub._config.topic_payloads_key, bad_id) # noqa: SLF001
415+
pipe.execute.assert_awaited_once()
416+
417+
303418
# --- eval_cached NOSCRIPT fallback (O1) ---
304419

305420

0 commit comments

Comments
 (0)