Metadata-Version: 2.4
Name: django-ticks
Version: 0.1.0
Summary: Periodic jobs for Django: declared in code, remembered in one table, run from cron or as a daemon
License-Expression: MIT
License-File: LICENSE
Keywords: django,scheduler,periodic,cron,jobs,postgresql
Author: Jan Rydzewski
Requires-Python: >=3.10
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Monitoring
Requires-Dist: django (>=4.2)
Project-URL: Repository, https://github.com/EE/django-ticks
Description-Content-Type: text/markdown

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

```python
# 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](https://github.com/EE/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`:

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

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

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

