Skip to content

Commit fe4fcdc

Browse files
committed
Add documentation for Redis Stream message claiming and min_idle_time usage
Signed-off-by: Victor Maleca <powersemmi@gmail.com>
1 parent 80f391d commit fe4fcdc

File tree

6 files changed

+225
-11
lines changed

6 files changed

+225
-11
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
---
2+
# 0.5 - API
3+
# 2 - Release
4+
# 3 - Contributing
5+
# 5 - Template Page
6+
# 10 - Default
7+
search:
8+
boost: 10
9+
---
10+
11+
# Redis Stream Message Claiming
12+
13+
When working with Redis Stream Consumer Groups, there may be situations where messages remain in a pending state because a consumer failed to process them. FastStream provides a mechanism to automatically claim these pending messages using Redis's `XAUTOCLAIM` command through the `min_idle_time` parameter.
14+
15+
## What is Message Claiming?
16+
17+
In Redis Streams, when a consumer reads a message from a consumer group but fails to acknowledge it (due to a crash, network issue, or processing error), the message remains in the **Pending Entries List (PEL)** of that consumer group. These unacknowledged messages are associated with the original consumer and have an "idle time" - the duration since they were last delivered.
18+
19+
Message claiming allows another consumer to take ownership of these pending messages that have been idle for too long, ensuring that messages don't get stuck and workload can be redistributed among healthy consumers.
20+
21+
## Using `min_idle_time` for Automatic Claiming
22+
23+
FastStream's `StreamSub` provides a `min_idle_time` parameter that enables automatic claiming of pending messages via Redis's `XAUTOCLAIM` command. When set, the consumer will automatically scan for and claim messages that have been pending for at least the specified duration (in milliseconds).
24+
25+
### Basic Example
26+
27+
Here's a simple example that demonstrates automatic message claiming:
28+
29+
```python linenums="1"
30+
{! docs_src/redis/stream/claiming_basic.py !}
31+
```
32+
33+
## How It Works
34+
35+
When `min_idle_time` is set:
36+
37+
1. **Circular Scanning**: Instead of using `XREADGROUP` to read new messages, the consumer uses `XAUTOCLAIM` to scan the Pending Entries List
38+
2. **Idle Time Check**: Only messages that have been pending for at least `min_idle_time` milliseconds are claimed
39+
3. **Ownership Transfer**: Claimed messages are automatically transferred from the failing consumer to the claiming consumer
40+
4. **Continuous Processing**: The scanning process is circular - after reaching the end of the PEL, it starts over from the beginning
41+
42+
### Practical Use Case
43+
44+
Consider a scenario where you have multiple workers processing orders:
45+
46+
```python linenums="1"
47+
from faststream import FastStream
48+
from faststream.redis import RedisBroker, StreamSub
49+
50+
broker = RedisBroker()
51+
app = FastStream(broker)
52+
53+
# Worker that might fail
54+
@broker.subscriber(
55+
stream=StreamSub(
56+
"orders",
57+
group="order-processors",
58+
consumer="worker-1",
59+
)
60+
)
61+
async def worker_that_might_fail(order_id: str):
62+
# Process order - might crash before acknowledging
63+
await process_complex_order(order_id)
64+
# If crash happens here, message stays pending
65+
66+
# Backup worker with message claiming
67+
@broker.subscriber(
68+
stream=StreamSub(
69+
"orders",
70+
group="order-processors",
71+
consumer="worker-2",
72+
min_idle_time=10000, # 10 seconds
73+
)
74+
)
75+
async def backup_worker(order_id: str):
76+
# This worker will automatically pick up messages
77+
# that worker-1 failed to process within 10 seconds
78+
print(f"Recovering and processing order: {order_id}")
79+
await process_complex_order(order_id)
80+
```
81+
82+
## Combining with Manual Acknowledgment
83+
84+
You can combine `min_idle_time` with manual acknowledgment policies for fine-grained control:
85+
86+
```python linenums="1"
87+
{! docs_src/redis/stream/claiming_manual_ack.py !}
88+
```
89+
90+
## Configuration Guidelines
91+
92+
### Choosing `min_idle_time`
93+
94+
The appropriate `min_idle_time` value depends on your use case:
95+
96+
- **Short duration (1-5 seconds)**: For fast-processing tasks where quick failure recovery is needed
97+
- **Medium duration (10-60 seconds)**: For most general-purpose applications with moderate processing times
98+
- **Long duration (5-30 minutes)**: For long-running tasks where you want to ensure a consumer has truly failed
99+
100+
!!! warning
101+
Setting `min_idle_time` too low may cause messages to be unnecessarily transferred between healthy consumers. Set it based on your typical message processing time plus a safety buffer.
102+
103+
### Deployment Patterns
104+
105+
#### Pattern 1: Dedicated Claiming Worker
106+
Deploy a separate worker specifically for claiming abandoned messages:
107+
108+
```python
109+
# Main workers (fast path)
110+
@broker.subscriber(
111+
stream=StreamSub("tasks", group="workers", consumer="main-1")
112+
)
113+
async def main_worker(task): ...
114+
115+
# Claiming worker (recovery path)
116+
@broker.subscriber(
117+
stream=StreamSub("tasks", group="workers", consumer="claimer", min_idle_time=15000)
118+
)
119+
async def claiming_worker(task): ...
120+
```
121+
122+
#### Pattern 2: All Workers Can Claim
123+
All workers can claim messages from each other:
124+
125+
```python
126+
# Each worker can both process new messages and claim abandoned ones
127+
@broker.subscriber(
128+
stream=StreamSub(
129+
"tasks",
130+
group="workers",
131+
consumer=f"worker-{instance_id}",
132+
min_idle_time=10000,
133+
)
134+
)
135+
async def worker(task): ...
136+
```
137+
138+
## Technical Details
139+
140+
- **Start ID**: FastStream automatically manages the `start_id` parameter for `XAUTOCLAIM`, enabling circular scanning through the Pending Entries List
141+
- **Empty Results**: When no pending messages meet the idle time criteria, the consumer will continue polling
142+
- **ACK Handling**: Claimed messages must still be acknowledged using `msg.ack()` to be removed from the PEL
143+
144+
## References
145+
146+
For more information about Redis Streams message claiming:
147+
148+
- [Redis XAUTOCLAIM Documentation](https://redis.io/docs/latest/commands/xautoclaim/){.external-link target="_blank"}
149+
- [Redis Streams Claiming Guide](https://redis.io/docs/latest/develop/data-types/streams/#claiming-and-the-delivery-counter){.external-link target="_blank"}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from faststream import FastStream, Logger
2+
from faststream.redis import RedisBroker, StreamSub
3+
4+
broker = RedisBroker()
5+
app = FastStream(broker)
6+
7+
8+
@broker.subscriber(
9+
stream=StreamSub(
10+
"orders",
11+
group="processors",
12+
consumer="worker-1",
13+
min_idle_time=5000, # Claim messages idle for 5+ seconds
14+
)
15+
)
16+
async def handle(order_id: str, logger: Logger):
17+
logger.info(f"Processing order: {order_id}")
18+
19+
20+
@app.after_startup
21+
async def publish_test():
22+
await broker.publish("order-123", stream="orders")
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from faststream import AckPolicy, FastStream, Logger
2+
from faststream.redis import RedisBroker, RedisStreamMessage, StreamSub
3+
4+
broker = RedisBroker()
5+
app = FastStream(broker)
6+
7+
8+
@broker.subscriber(
9+
stream=StreamSub(
10+
"critical-tasks",
11+
group="task-workers",
12+
consumer="worker-failover",
13+
min_idle_time=30000, # 30 seconds
14+
),
15+
ack_policy=AckPolicy.MANUAL,
16+
)
17+
async def handle(msg: RedisStreamMessage, logger: Logger):
18+
try:
19+
# Process the claimed message
20+
logger.info(f"Processing: {msg.body}")
21+
# Explicitly acknowledge after successful processing
22+
await msg.ack()
23+
except Exception as e:
24+
# Don't acknowledge - let it be claimed by another consumer
25+
logger.error(f"Failed to process: {e}")
26+
27+
28+
@app.after_startup
29+
async def publish_test():
30+
await broker.publish("critical-task-1", stream="critical-tasks")

faststream/redis/subscriber/usecases/stream_subscriber.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def __init__(
4848
self._stream_sub = config.stream_sub
4949
self.last_id = config.stream_sub.last_id
5050
self.min_idle_time = config.stream_sub.min_idle_time
51-
self.autoclaim_start_id = "0-0"
51+
self.autoclaim_start_id = b"0-0"
5252

5353
@property
5454
def stream_sub(self) -> "StreamSub":
@@ -201,9 +201,7 @@ async def xautoclaim() -> tuple[
201201
stream_name = self.stream_sub.name.encode()
202202
(next_id, messages, _) = stream_message
203203
# Update start_id for next call
204-
self.autoclaim_start_id = (
205-
next_id.decode() if isinstance(next_id, bytes) else next_id
206-
)
204+
self.autoclaim_start_id = next_id
207205
if not messages:
208206
return ()
209207
return ((stream_name, messages),)
@@ -251,9 +249,7 @@ async def get_one(
251249
)
252250
(next_id, messages, _) = stream_message
253251
# Update start_id for next call
254-
self.autoclaim_start_id = (
255-
next_id.decode() if isinstance(next_id, bytes) else next_id
256-
)
252+
self.autoclaim_start_id = next_id
257253
if not messages:
258254
return None
259255
stream_name = self.stream_sub.name.encode()
@@ -323,9 +319,7 @@ async def __aiter__(self) -> AsyncIterator["RedisStreamMessage"]: # type: ignor
323319
)
324320
(next_id, messages, _) = stream_message
325321
# Update start_id for next call
326-
self.autoclaim_start_id = (
327-
next_id.decode() if isinstance(next_id, bytes) else next_id
328-
)
322+
self.autoclaim_start_id = next_id
329323
if not messages:
330324
continue
331325
stream_name = self.stream_sub.name.encode()

tests/brokers/redis/test_config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ def test_stream_with_group() -> None:
4747
assert config.ack_policy is AckPolicy.REJECT_ON_ERROR
4848

4949

50+
@pytest.mark.redis()
51+
def test_stream_sub_with_no_ack_group() -> None:
52+
with pytest.warns(
53+
RuntimeWarning,
54+
match="`no_ack` is not supported by consumer group with last_id other than `>`",
55+
):
56+
config = RedisSubscriberConfig(
57+
_outer_config=MagicMock(),
58+
stream_sub=StreamSub(
59+
"test_stream",
60+
group="test_group",
61+
consumer="test_consumer",
62+
no_ack=True,
63+
last_id="$",
64+
),
65+
)
66+
assert config.ack_policy is AckPolicy.MANUAL
67+
68+
5069
@pytest.mark.redis()
5170
def test_stream_with_group_and_min_idle_time() -> None:
5271
config = RedisSubscriberConfig(

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)