Consumer Groups and Message Claiming both rely on Redis's Pending Entries List (PEL) to track messages that were delivered but not yet acknowledged. Exercising that lifecycle against a real Redis instance means waiting out real min_idle_time timers, which makes tests slow and non-deterministic.
TestRedisBroker avoids that by emulating the PEL in memory. It applies the same rules a real Redis consumer group would, but synchronously - so a min_idle_time consumer can reclaim a nacked message within the very same awaitbroker.publish(...) call instead of waiting for a real timeout.
If nothing in the group has min_idle_time set, a nacked message simply stays pending. Here, flaky_worker always fails, and there's no one to reclaim its work:
fromfaststreamimportFastStream,Loggerfromfaststream.exceptionsimportNackMessagefromfaststream.redisimportRedisBroker,StreamSubbroker=RedisBroker()app=FastStream(broker)@broker.subscriber(stream=StreamSub("orders",group="order-processors",consumer="worker-1"),)asyncdefflaky_worker(order_id:str,logger:Logger)->None:logger.info(f"Failed to process order: {order_id}")raiseNackMessage
Pass a PEL instance to TestRedisBroker to inspect it directly after publishing. A pel fixture keeps every test working against its own instance:
@pytest.mark.asyncioasyncdeftest_pending_message_stays_without_a_claimer()->None:pel=PEL()asyncwithTestRedisBroker(pending_broker,pel=pel)asbr:awaitbr.publish("order-1",stream="orders")pending_worker.mock.assert_called_once_with("order-1")# nothing exists to reclaim it, so the entry just stays pendingassertlen(pel.entries)==1@pytest.mark.asyncioasyncdeftest_each_group_gets_its_own_pel_entry()->None:
The PEL is tracked per consumer group, not per message. If the same stream has several groups subscribed - each modeling an independent workload - a single published message that goes unacknowledged in every group leaves one pending entry per group, not one shared entry:
fromfaststreamimportFastStream,Loggerfromfaststream.exceptionsimportNackMessagefromfaststream.redisimportRedisBroker,StreamSubbroker=RedisBroker()app=FastStream(broker)@broker.subscriber(stream=StreamSub("orders",group="billing",consumer="worker-1"),)asyncdefbilling_worker(order_id:str,logger:Logger)->None:logger.info(f"Billing failed for order: {order_id}")raiseNackMessage@broker.subscriber(stream=StreamSub("orders",group="shipping",consumer="worker-1"),)asyncdefshipping_worker(order_id:str,logger:Logger)->None:logger.info(f"Shipping failed for order: {order_id}")raiseNackMessage
awaitbr.publish("order-4",stream="orders")billing_worker.mock.assert_called_once_with("order-4")shipping_worker.mock.assert_called_once_with("order-4")# one entry per group, tracked independently for the same messageassertlen(pel.entries)==2@pytest.mark.asyncio
This mirrors real Redis: XPENDING is scoped to a single consumer group, so groups never see or interfere with each other's pending entries, even when they're reading the same stream.
fromfaststreamimportFastStream,Loggerfromfaststream.exceptionsimportNackMessagefromfaststream.redisimportRedisBroker,StreamSubbroker=RedisBroker()app=FastStream(broker)while(len(broker.subscribers)<2orbroker.subscribers[0].specification.call_name!="FlakyWorker"):broker=RedisBroker()@broker.subscriber(stream=StreamSub("orders",group="order-processors",consumer="worker-1"),)asyncdefflaky_worker(order_id:str,logger:Logger)->None:logger.info(f"Failed to process order: {order_id}")raiseNackMessage@broker.subscriber(stream=StreamSub("orders",group="order-processors",consumer="claimer",min_idle_time=10000,# 10 seconds),)asyncdefclaiming_worker(order_id:str,logger:Logger)->None:logger.info(f"Recovered order: {order_id}")
Unlike a real broker, the fake min_idle_time consumer doesn't wait for the idle timeout to elapse - it checks the PEL immediately, so the reclaim happens within the same publish() call that produced the pending entry.
fromfaststreamimportFastStream,Loggerfromfaststream.redisimportRedisBroker,StreamSubbroker=RedisBroker()app=FastStream(broker)@broker.subscriber(stream=StreamSub("orders",no_ack=True))asyncdeffire_and_forget_worker(order_id:str,logger:Logger)->None:logger.info(f"Processing order: {order_id}")error_msg=f"Could not process order: {order_id}"raiseValueError(error_msg)
asyncwithTestRedisBroker(no_ack_broker,pel=pel)asbr:withpytest.raises(ValueError,match="Could not process order"):awaitbr.publish("order-3",stream="orders")fire_and_forget_worker.mock.assert_called_once_with("order-3")# no_ack means nothing was ever recorded, failure or notassertpel.entries=={}
By default, each TestRedisBroker creates its own private PEL. Passing one explicitly (as in the examples above) lets you assert against it directly - either by reading pel.entries, or through the put/remove spies it exposes: