Declarative schedules
Declarative schedules put the definitions in your application's repository and push them to the brain as one batch. The brain upserts what changed and deletes the rows that carry the same source label and are no longer in the batch, so the dict in your code is the whole truth for that label.
Use it when schedules belong with the code that handles them (review, history, revert), when environments differ, or when adding a feature and its schedule should land together. Use the dashboard or the one-shot importers instead when schedules are edited by operators or when you are migrating off another tool once.
Two implementations ship, and they are separate. Pick one per project.
With the framework adapter
Section titled “With the framework adapter”The framework adapters (z4j-django, z4j-flask, z4j-fastapi) read a plain dict
and reconcile it. Each entry needs task, kind and expression; args,
kwargs, queue and timezone are optional. An entry that is not a dict, or
that lacks one of the three required keys, is skipped with a warning rather
than failing the batch.
Z4J_SCHEDULES = { "nightly-billing": { "task": "billing.tasks.run_nightly", "kind": "cron", "expression": "0 2 * * *", "queue": "billing", "timezone": "Europe/Amsterdam", },}There is no per-entry enabled or catch-up key: every reconciled row is enabled
with catch-up skip.
| Key | Default | Effect |
|---|---|---|
Z4J_SCHEDULES |
{} |
The schedule dict above. |
Z4J_RECONCILE_CELERY_BEAT |
false |
Also translate CELERY_BEAT_SCHEDULE. On a name clash Z4J_SCHEDULES wins. |
Z4J_SCHEDULE_DEFAULT_ENGINE |
celery |
Engine written on rows that name none. |
Z4J_SCHEDULE_OWNER |
none | Overrides the project's default_scheduler_owner for these writes. |
Z4J_RECONCILE_SOURCE_TAG |
declarative:django or declarative:flask |
The source label these rows carry, and therefore what replace-for-source may delete. FastAPI takes reconcile_source_tag, default declarative:fastapi. |
Z4J_RECONCILE_AUTORUN |
false |
Flask only, in app.config: reconcile at init_app. FastAPI takes reconcile_autorun. The Django adapter has no auto-run; call the management command from a deploy hook. |
Brain URL, token and project id come from the adapter's normal Z4J settings.
The token has to be a project admin key, because the import route is admin only.
Run it from a deploy hook:
python manage.py z4j_reconcile --dry-run # Django, previews without writingpython manage.py z4j_reconcileflask z4j-reconcile # FlaskBoth commands take --dry-run and --json. They exit 0 on success, on a no-op,
and when the brain settings are missing (that case only logs a warning), and 1
when the brain rejects the batch or any row fails. --dry-run calls the diff
route, so it writes nothing and leaves no audit row. The JSON carries ok,
dry_run, inserted, updated, unchanged, failed, deleted and a
per-row errors map.
With the z4j-scheduler package
Section titled “With the z4j-scheduler package”z4j_scheduler.declarative takes typed specs instead of a dict:
from z4j_scheduler.declarative import ScheduleSpec, reconcile
SCHEDULES = [ ScheduleSpec( name="nightly-billing", engine="celery", kind="cron", expression="0 2 * * *", task_name="billing.tasks.run_nightly", timezone="Europe/Amsterdam", ),]ScheduleSpec also takes queue, args, kwargs, catch_up (default skip)
and is_enabled (default true). Use cron, interval, clocked or solar
for kind; the one_shot value in the type hint is not a kind the brain
accepts. A name-keyed dict works too, but each key has to equal its spec's
name.
reconcile(...) is a coroutine; reconcile_sync(...) is for hooks with no
event loop and raises if one is already running.
Framework helpers wrap it:
- Django: add
z4j_scheduler.django_apptoINSTALLED_APPS, then setZ4J_SCHEDULES,Z4J_SCHEDULES_PROJECT(required),Z4J_SCHEDULES_BRAIN_URL(defaulthttp://brain:7700),Z4J_SCHEDULES_API_TOKENand optionallyZ4J_SCHEDULES_SOURCE(defaultdeclarative_django) in Django settings. The token is read from settings, not from the environment. SetZ4J_SCHEDULES_AUTO_RECONCILE = Trueto reconcile on every process boot; it is off by default because most deployments prefer a one-shot deploy step. - Flask:
register_z4j_schedules(app, schedules=..., project=..., api_token=...)reconciles at once and registersflask z4j-schedules-sync. - FastAPI:
z4j_scheduler.declarative.frameworks.fastapi.z4j_lifespan(...). This is a different function fromz4j_fastapi.z4j_lifespan, which installs the agent.
The Django command is manage.py z4j_schedules with sync, list, diff or
trigger (trigger needs --name), and --json on all four. sync fails
with exit 1 only when the settings are unusable, so read the printed failed
count rather than the exit status alone. diff compares content hashes and
writes nothing.
What the brain does with the batch
Section titled “What the brain does with the batch”Both paths post to POST /api/v1/projects/{slug}/schedules:import as a project
admin, with mode=replace_for_source and a source label. The brain:
- Upserts each row by project, scheduler and name. A row whose
source_hashis unchanged counts as unchanged and is not rewritten. - Deletes every schedule in the project that carries the same
sourcelabel and was not in the batch. - Writes one
schedules.importaudit row for the batch, with the mode, the counts and the source filter.
Batches are capped at 2000 rows and serialised per project by an advisory lock.
The label has to be one the brain allows for replace-for-source: the
declarative:django, declarative:flask, declarative:fastapi,
declarative_django, declarative_flask, declarative_fastapi and
declarative labels, plus the imported_* labels the migration importers
write. Anything else, including dashboard, is refused with 422. That is the
guard rail: a declarative reconcile can never delete a schedule somebody
created in the dashboard.
POST /api/v1/projects/{slug}/schedules:diff takes the same body and returns
the insert, update, unchanged and delete sets without writing or auditing. It is
what --dry-run calls.
See also
Section titled “See also”- z4j-scheduler for the scheduler service itself.
- Schedules API for the routes these commands call.