Backends
Distributed Locking
Stop the same job running on every replica at once.
Why you need it
The scheduler runs inside your application process. Three replicas means three schedulers, and a job set to run at midnight runs three times at midnight. A distributed lock makes exactly one replica win the race and the others skip that tick.
Locking is off by default
A single-instance deployment does not need it, so it is opt-in. Turn it on before you scale past one replica.
Available backends
| Backend | Import from | Extra needed | Notes |
|---|---|---|---|
LocalLockBackend | fastapi_crons | — | In-process only. The default, and useless across replicas. |
RedisLockBackend | fastapi_crons | — | The usual choice. TTL-based. |
SQLAlchemyLockBackend | fastapi_crons.locking.sqlalchemy | sqlalchemy | A lock table in your own database. Works on any dialect. |
PostgreSQLAdvisoryLockBackend | fastapi_crons.locking.sqlalchemy | sqlalchemy | PostgreSQL advisory locks. No table, released automatically. |
Redis
import redis.asyncio as redis
from fastapi_crons import Crons, DistributedLockManager, RedisLockBackend
client = redis.from_url("redis://localhost:6379")
locks = DistributedLockManager(RedisLockBackend(client), lock_ttl=300)
crons = Crons(app, lock_manager=locks)Or let the scheduler build it for you from the environment:
CRON_ENABLE_DISTRIBUTED_LOCKING=true
CRON_REDIS_URL=redis://localhost:6379
CRON_LOCK_TTL=300SQLAlchemy
Keeps locks in a cron_locks table, so you do not need Redis just for this.
from sqlalchemy.ext.asyncio import create_async_engine
from fastapi_crons import Crons, DistributedLockManager
from fastapi_crons.locking.sqlalchemy import SQLAlchemyLockBackend
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/app")
locks = DistributedLockManager(SQLAlchemyLockBackend(engine))
crons = Crons(app, lock_manager=locks)Parameters
True.PostgreSQL advisory locks
Advisory locks live in the PostgreSQL session rather than a table, so a crashed replica releases its lock the moment its connection drops — no TTL to tune and nothing to clean up.
from sqlalchemy.ext.asyncio import create_async_engine
from fastapi_crons import DistributedLockManager
from fastapi_crons.locking.sqlalchemy import PostgreSQLAdvisoryLockBackend
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/app")
locks = DistributedLockManager(PostgreSQLAdvisoryLockBackend(engine))Async engine required
This backend holds a connection open for the lifetime of the lock, so it rejects sync engines with a TypeError. Build the engine with create_async_engine() and the asyncpg driver.
Choosing a TTL
For the TTL-based backends, lock_ttl is how long a lock survives if the holder dies without releasing it. Too short and a long job loses its lock while still running, letting another replica start a second copy. Too long and a crashed replica blocks that job until the TTL expires.
Set the TTL above your worst-case run time
Take the longest the job has ever taken, including retries, and add margin. The default of 300 seconds suits jobs that finish in a minute or two.
What happens when a lock is held
The replica that fails to acquire the lock skips that tick and waits for the next one. It is not queued and does not run late — a skipped tick is simply not executed by that replica.