Skip to content

Type-safe task definitions

A schedule names a task and, optionally, the arguments it runs with. Goatbeat types both so that pyright or mypy catch mistakes before deployment.

Tasks without arguments

Pass the function itself:

beat.schedule(name="cleanup", task=cleanup, every="1h")

Goatbeat stores the task by its import path (myproject.tasks.cleanup), so the function must be importable at module level: lambdas and nested functions are rejected.

Tasks with arguments: call

from goatbeat import call


def send_email(user_id: int, *, subject: str = "hello") -> None: ...


beat.schedule(name="welcome", task=call(send_email, 42, subject="hi"), every="1d")
call(send_email, "42")  # type error: str is not int
call(send_email)  # type error: user_id is missing
call(send_email, 1, unknown=1)  # type error: no such parameter

call uses ParamSpec, so its signature is the task's own. Arguments are stored in the database, so they must be JSON values: strings, numbers, booleans, None, lists and string-keyed dicts. NaN, infinities and NUL characters are rejected because PostgreSQL jsonb cannot store them.

The declaration itself

Registry.schedule has two overloads: one with cron= and timezone=, one with every= and start_at=. Passing both, or neither, is a type error and a runtime ConfigurationError. misfire_policy only accepts "run_once", "skip", "catch_up" or a MisfirePolicy.

Django Tasks

goatbeat.django.call is typed on django.tasks.Task[P, R], so the same checks apply to @task functions. DjangoRegistry.schedule accepts a zero-argument Task or the result of call; passing a task that needs arguments without call is a type error.

Celery, Dramatiq and RQ

RQ takes plain functions, so task=send_email and call(send_email, 42) work as shown above. Celery tasks and Dramatiq actors are wrapper objects whose import path is not the name their workers know, so the core call rejects them. Use the backend's own call:

from goatbeat.celery import call as celery_call
from goatbeat.dramatiq import call as dramatiq_call

beat.schedule(name="charge", task=celery_call(charge, 10), every="1h")
beat.schedule(name="welcome", task=dramatiq_call(send_welcome, 42), every="1d")

goatbeat.celery.call uses the registered task name, custom name= included. goatbeat.dramatiq.call uses the module attribute that holds the actor. Both check arguments like the core call, except that Celery types @app.task loosely: @shared_task keeps the signature. TaskCall("name", args) also works when the task object is not importable where the schedules are declared.

goatbeat doctor checks that every task path imports, so it reports a Celery task whose name= is not its import path; that report is expected.

String paths

Only the admin and the django-celery-beat importer create schedules from strings. The admin only accepts django.tasks.Task paths; the importer imports other paths disabled, with a note.