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 that — bounded by the
max_concurrentcapacity limiter — runs an atomic Lua claim script that:- Verifies the timer is still due (score ≤ now)
- Pushes its score forward by
lease_ttlseconds — granting 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 acts as 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 queue has work 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. Worst-case delivery latency on a previously-idle queue is max_polling_interval × 1.5 (with ±50% jitter) |
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 |
Operational requirements¶
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).
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.