FastAPI-Cronsv2.4.0

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

BackendImport fromExtra neededNotes
LocalLockBackendfastapi_cronsIn-process only. The default, and useless across replicas.
RedisLockBackendfastapi_cronsThe usual choice. TTL-based.
SQLAlchemyLockBackendfastapi_crons.locking.sqlalchemysqlalchemyA lock table in your own database. Works on any dialect.
PostgreSQLAdvisoryLockBackendfastapi_crons.locking.sqlalchemysqlalchemyPostgreSQL advisory locks. No table, released automatically.

Redis

python
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:

bash
CRON_ENABLE_DISTRIBUTED_LOCKING=true
CRON_REDIS_URL=redis://localhost:6379
CRON_LOCK_TTL=300

SQLAlchemy

Keeps locks in a cron_locks table, so you do not need Redis just for this.

python
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

engine
Engine | AsyncEngine
A SQLAlchemy engine.
create_tables
bool
Create the lock table on first use. Defaults to True.
instance_id
str | None
Owner recorded on the lock. Defaults to the configured instance id.

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.

python
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.

Esc
IntroductionGetting StartedInstallationGetting StartedQuick StartGetting StartedCron ExpressionsCore ConceptsDefining JobsCore ConceptsHooksCore ConceptsConfigurationCore ConceptsState BackendsBackendsDistributed LockingBackendsDashboardOperationsHTTP EndpointsOperationsCLIOperations