Skip to main content

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

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_ticks-0.1.0.tar.gz (7.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_ticks-0.1.0-py3-none-any.whl (10.0 kB view details)

Uploaded Python 3

File details

Details for the file django_ticks-0.1.0.tar.gz.

File metadata

  • Download URL: django_ticks-0.1.0.tar.gz
  • Upload date:
  • Size: 7.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_ticks-0.1.0.tar.gz
Algorithm Hash digest
SHA256 9fffc6f58276c442aae5b04e0e92635c55e62510b6699dc3f2f5cc876b4a7e6c
MD5 71005cf287e74b04e20d57508a0df059
BLAKE2b-256 6ff308168fc8176efdb35fb94487f0633ce68d5cb367131ee935781b4cbfdae7

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_ticks-0.1.0.tar.gz:

Publisher: pypi.yml on EE/django-ticks

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_ticks-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: django_ticks-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 10.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_ticks-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5ad2d2519cb26a8f24d79826d32ba54cc23b97ef55e79acbb9835e6e491b2b58
MD5 e7b450c49d1c6dfa7eba5cc9c9b4dd15
BLAKE2b-256 092a71d9820b9885583d7b3465c223d2b180ce0a7f9dde8491b77c9b686d193d

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_ticks-0.1.0-py3-none-any.whl:

Publisher: pypi.yml on EE/django-ticks

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page