Skip to content

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:

  1. 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
  2. For each due timer, spawns a task (bounded by the max_concurrent capacity limiter) that runs an atomic Lua claim script that:
    • Verifies the timer is still due (score ≤ now)
    • Pushes its score forward by lease_ttl seconds, which grants the worker a lease
    • Returns the payload
  3. Delivers the payload to the user handler
  4. On handler success, runs an atomic Lua commit script (ZREM + HDEL) to remove the timer from Redis
  5. 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.