Skip to content

Repository files navigation

Django Ticks

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 idea

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_job call 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 of django_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. tick runs what is due and exits, which suits cron, a Kubernetes CronJob or Heroku Scheduler. tick --forever sleeps 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 a stop_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.

Deliberately not here

  • 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 (run and run_job in models.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.

Requirements

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

Installation

pip install django-ticks

Add django_ticks to INSTALLED_APPS and run migrate.

Declaring jobs

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

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

Running

./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 returns

Admin

The Job model is registered in the admin as a read-only list: keys and their last_run. It exists to look, not to configure.

Development

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages