Periodic jobs for Django, declared in code and remembered in one database table. Run a tick from cron, as a daemon, or as many daemons as you like: the process holds no state.
# myapp/ticks.py
from datetime import timedelta
from django_ticks.models import register_job
from .stats import rebuild_stats
register_job(rebuild_stats, timedelta(hours=1))$ ./manage.py tick # run whatever is due, then exit
$ ./manage.py tick --forever # keep running, sleep until the next job is due
The result of a tick is a function of the code, the table and the clock. The process running it is interchangeable.
- Code declares.
A job is a
register_jobcall executed at import time: a handler, an interval, and a key that defaults to the handler's module path and name. The database cannot create or edit a job, and the admin is read-only. Adding a job is a code change and a deploy; its row appears on the first tick. - The database remembers.
One row per job, holding
last_run. That is all the state. A daily job does not fire again because a container restarted, and a job added a year ago does not fire twice because two deploys overlapped. Think ofdjango_migrations: the code is the truth, the table is a bookmark. - The database coordinates.
A tick locks the job's row for the duration of the handler
(
SELECT ... FOR UPDATE), so any number of processes can tick at once and a job still runs in one place at a time. No broker, no leader election, no lock file. - The process is disposable.
tickruns what is due and exits, which suits cron, a KubernetesCronJobor Heroku Scheduler.tick --foreversleeps until the next job is due, which suits a long-running container. Both at once, or five of the latter, is fine too.run()also accepts astop_event, so the loop can live in a thread of a process you already have.
A handler runs inside the transaction that holds the row lock,
in a savepoint of its own:
its writes commit together with the last_run update,
and if it raises, its writes roll back,
the error is logged, and last_run still advances,
so the job runs again after its interval rather than every tick.
Keep handlers short:
a long handler holds its row lock and an open transaction
for as long as it runs.
Heavy work belongs in a task queue the handler merely feeds.
- Run history. One row per job, not per run. Your logs have the history.
- Retries. A failing job is retried by its own interval. A retry loop on a job that ticks every second is worse than a skipped run.
- Cron expressions and clock alignment.
Jobs run at "last run plus interval", not "at 03:00".
The next run is computed in one place (
runandrun_jobinmodels.py), so clock-aligned schedules could be added without changing the model; they have not been needed. - Per-job parallelism. A job runs in one process at a time. That is what the lock is for.
- A task queue. There are no workers, no queues, no payloads. For one-shot work with retries and dependencies, django-goals is built on the same database-only conviction; the two packages are independent.
- PostgreSQL. The row lock uses
select_for_update(no_key=True), which Django supports on PostgreSQL only. - Django 4.2 or later, Python 3.10 or later.
pip install django-ticks
Add django_ticks to INSTALLED_APPS and run migrate.
register_job(handler, interval, key=None) records a job
in the current process.
The handler is called as handler(now=...)
with the timestamp the tick started at.
Registrations must have happened before run() starts,
so put them in a module imported from your AppConfig.ready:
class MyAppConfig(AppConfig):
name = 'myapp'
def ready(self):
import myapp.ticks # noqa: F401The key identifies the job's row.
It defaults to module.function,
so renaming or moving a handler creates a fresh row
that runs on the next tick as if it had never run.
Pass key= to keep the identity stable across a rename,
or when the handler has no useful name:
register_job(
lambda now: call_command('clearsessions'),
interval=timedelta(days=1),
key='clearsessions',
)Rows of jobs that no longer exist in code are left alone. The admin is read-only, so drop them from a shell if they bother you.
./manage.py tick runs every job that is due and exits.
./manage.py tick --forever keeps running,
sleeping until the next job is due.
Stop it with SIGTERM or Ctrl-C.
A handler interrupted mid-run is rolled back
together with its last_run update,
so the job runs again on the next tick.
To embed the loop in another process, call run directly:
import threading
from django_ticks.models import run
stop_event = threading.Event()
threading.Thread(target=run, kwargs={'stop_event': stop_event}).start()
...
stop_event.set() # the loop wakes from its sleep and returnsThe Job model is registered in the admin as a read-only list:
keys and their last_run.
It exists to look, not to configure.
The test suite needs a PostgreSQL database.
cp example.env .env # then point DATABASE_URL at your database
poetry install
poetry run pytest
poetry run flake8