How it works¶
Redis data structures¶
faststream-redis-timers uses two Redis keys per topic to store scheduled timers:
| Key pattern | Type | Purpose |
|---|---|---|
timers_timeline:{topic} |
Sorted set | Maps each timer_id to its activation Unix timestamp (score) |
timers_payloads:{topic} |
Hash | Maps each timer_id to its serialized message body |
When you publish a timer for topic invoices with timer_id abc-123, both keys are written atomically using a Redis pipeline:
ZADD timers_timeline:invoices <activation_ts> abc-123
HSET timers_payloads:invoices abc-123 <encoded_envelope>
The envelope is FastStream's BinaryMessageFormatV1, a binary-safe wire format that carries the message body alongside correlation_id, content-type, reply_to, and any user-supplied headers. The same format is used by FastStream's built-in Redis broker.
Polling loop¶
Each subscriber runs a background polling loop that:
- Calls
ZRANGEBYSCORE timers_timeline:{topic} -inf <now> LIMIT 0 <free>(where<free>is the unused capacity from the per-subscriber concurrency limiter) to find due timers - For each due timer, spawns a task (bounded by the
max_concurrentcapacity limiter) that runs an atomic Lua claim script that:- Verifies the timer is still due (score ≤ now)
- Pushes its score forward by
lease_ttlseconds, which grants the worker a lease - Returns the payload
- Delivers the payload to the user handler
- On handler success, runs an atomic Lua commit script (
ZREM+HDEL) to remove the timer from Redis - On handler exception, leaves the timer in place; the lease eventually expires and another worker re-claims it
This is the standard SQS-style visibility-timeout pattern. The timer's own score in the sorted set is the lease deadline; there is no separate lock primitive.
At-least-once delivery¶
The lease ensures each due timer is processed by exactly one worker at a time. Because the timer is removed from Redis only after the handler completes successfully, no timer is lost on crash: if the worker dies mid-handler, the lease expires and the timer is re-delivered.
The trade-off: handlers that take longer than lease_ttl, or workers that crash after the handler ran but before the commit landed, may see the timer delivered more than once. Handlers must therefore be idempotent.
Ack / Nack / Reject¶
| Action | Effect |
|---|---|
ack |
Atomically removes the timer from timers_timeline and timers_payloads |
nack |
No-op; the lease expires and another worker re-claims the timer |
reject |
Same as ack: permanently removes the timer (use for poison-pill messages) |
The default ack policy is NACK_ON_ERROR: the timer is acknowledged on success, and left for retry on any unhandled exception.
Key configuration¶
Broker constructor (TimersBroker(...))¶
| Parameter | Default | Description |
|---|---|---|
timeline_key |
timers_timeline |
Prefix for sorted set keys ({timeline_key}:{topic}) |
payloads_key |
timers_payloads |
Prefix for hash keys ({payloads_key}:{topic}) |
start_timeout |
3.0 s |
Max wait for the first Redis ping during startup |
Per-subscriber (@broker.subscriber("topic", ...))¶
| Parameter | Default | Description |
|---|---|---|
polling_interval |
0.05 s |
Base poll interval used when the topic has due timers or just transitioned from idle |
max_polling_interval |
5.0 s |
Ceiling for the adaptive idle backoff: polling_interval doubles up to this value on consecutive empty polls. Jitter is applied before the cap, so worst-case delivery latency on a previously-idle topic is max_polling_interval |
max_concurrent |
5 |
Max handlers running concurrently per subscriber; also bounds fetch batch size |
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 |
command_timeout |
None (lease_ttl) |
Most time the subscriber waits on one Redis command (fetch, claim, ack). A primary that goes silent without closing its connections otherwise stalls the subscriber; redis-py before 8.0 sets no socket timeout by default. Must be > 0 and <= lease_ttl |
Operational requirements¶
faststream-redis-timers is designed for a single-primary Redis (Sentinel-managed primary/replica setups are supported; Redis Cluster is not: constructing TimersBroker with a RedisCluster client raises TypeError).
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.