Skip to content

z4j-scheduler - the dynamic scheduler service

z4j-scheduler is a separate companion process that fires schedules against any z4j-supported engine (Celery, RQ, Dramatiq, Huey, arq, taskiq). Unlike the per-engine scheduler adapters in this section (which surface celery-beat / rq-scheduler / APScheduler / etc. in the dashboard), z4j-scheduler IS the scheduler - operators who choose it delete celery-beat from their stack.

A single-binary, leader-elected, gRPC-connected scheduler service. Three responsibilities:

  1. Tick - once per second, scan z4j’s schedules table for rows due to fire.
  2. Dispatch - for each due fire, send a FireSchedule gRPC frame to z4j. Brain dispatches a schedule.fire command to the project’s online agent.
  3. Reconcile - every 15 minutes (configurable), do a full sync from brain so a missed delete event in the watch stream doesn’t leave a stale schedule firing forever.

Idle CPU is essentially zero. Memory is bounded by the number of schedules.

How it differs from the per-engine scheduler adapters

Section titled “How it differs from the per-engine scheduler adapters”

The packages on the schedulers overview page (z4j-celerybeat, z4j-rqscheduler, etc.) are adapters - they surface an EXISTING native scheduler (celery-beat, rq-scheduler) in the z4j dashboard so operators can see and edit those schedules in one UI.

z4j-scheduler is a replacement - it owns the schedule storage itself, in z4j’s database, and dispatches across any engine. Operators who run z4j-scheduler don’t need celery-beat or rq-scheduler at all.

The marketing version of this matrix lives at z4j.com/schedulers/z4j-scheduler broken into six categorized tables. The terse engineering version follows.

Capabilitycelery-beatdjango-celery-beatrq-schedulerAPScheduler 4system cronz4j-scheduler
Engines supportedCelery onlyCelery onlyRQ onlyAPScheduler in-processShell execAll 6: Celery, RQ, Dramatiq, Huey, arq, taskiq
Framework agnosticYesDjango onlyYesYesYes (host OS)Yes - Django/Flask/FastAPI/bare
Multiple engines, one processNoNoNoNoNoYes
Capabilitycelery-beatdjango-celery-beatrq-schedulerAPScheduler 4system cronz4j-scheduler
Edit live (no restart)NoDjango admin onlyNoPersistent jobstore onlyNoDashboard / declarative / REST
Built-in dashboardNoDjango admin (basic)NoNoNoYes - fire history, run-now, edit
Fire history per scheduleNoNoNoNosyslog onlyBuffered + acked + searchable
Audit log of editsNoDjango auditlog (3rd party)NoNoNoHMAC-chained, tamper-evident
Manual fire-now buttonNoNoNoAPI onlyNoYes - dashboard + REST
RBAC / project scopingNoDjango auth onlyNoNoUNIX permissionsProject + role-scoped
Capabilitycelery-beatdjango-celery-beatrq-schedulerAPScheduler 4system cronz4j-scheduler
HA / leader electionSingle onlySingle onlySingle onlyPluggable (manual)Single hostPostgres advisory-lock leader, rolling-restart safe
Catch-up on outageAll-or-nothingAll-or-nothingDefault fire-allCoalescing onlyNoPer-schedule: skip / fire-one-missed / fire-all-missed
DST / IANA tz correctnessYesYesPartialYesYesYes (validated at API)
Solar (sunrise/sunset)NoYesNoNoNoYes
Replay past firesNoNoNoNoNoYes
Capabilitycelery-beatdjango-celery-beatrq-schedulerAPScheduler 4system cronz4j-scheduler
Importer FROM other schedulersN/AN/AN/AN/AN/AAll 6 native schedulers + crontab
Exporter TO other schedulersN/AN/AN/AN/AN/AAll 6 - no lock-in (round-trip pinned by tests)
Coexist with native scheduler-----Yes - z4j-celerybeat coexistence adapter
Declarative-in-sourceYes (beat_schedule)No (DB-only)ManualYescrontab fileYes - z4j_scheduler.declarative reconciler
Capabilitycelery-beatdjango-celery-beatrq-schedulerAPScheduler 4system cronz4j-scheduler
Wire HMAC + replay protectionBroker-dependentBroker-dependentNoNoN/AHMAC-SHA256 + per-session seq+nonce binding
Tamper-evident audit chainNoNoNoNoNoPer-row HMAC, prev-hmac chain, DB UNIQUE
Zero-downtime secret rotationN/AN/AN/AN/AN/AYes - Z4J_PREVIOUS_SECRETS multi-key window
Capabilitycelery-beatdjango-celery-beatrq-schedulerAPScheduler 4system cronz4j-scheduler
LicenseBSD-3BSD-3MITMITBSDApache-2.0
Process modelLong-running daemonDaemon + DBLong-running daemonIn-process or daemoninit / systemdStandalone OR brain-embedded subprocess
Idle CPU footprintLowLowLowLowNegligibleNegligible (1s tick, semaphore-bounded)
┌─────────────────────────────────────────────┐
│ Your app (Django/Flask/FastAPI/bare) │
│ + Celery (or RQ / arq / Huey / etc.) │
│ + z4j-X engine adapter │
│ + z4j-bare agent runtime │
└──────────────────┬──────────────────────────┘
│ WebSocket / longpoll
┌─────────────────────────────────────────────┐
│ z4j ← server + dashboard │
│ z4j-scheduler ← fires schedules at the │
│ right time (the new bit) │
└─────────────────────────────────────────────┘

z4j-scheduler does not run tasks. It only decides WHEN they should run and tells brain to dispatch them. Your existing engine worker (celery, RQ worker, etc.) runs the tasks on its own broker. This means:

  • No new broker. Your existing Redis / RabbitMQ / Postgres / etc. stays as the message bus.
  • No new worker process. Your existing celery worker / rq worker / arq worker continues running tasks unchanged.
  • The only new thing is one scheduler process (or a few, for HA).

For homelab / single-instance deploys, z4j can spawn z4j-scheduler as a supervised subprocess in its own lifespan. Auto-mints loopback mTLS at boot.

Z4J_EMBEDDED_SCHEDULER=true
Z4J_SCHEDULER_GRPC_ENABLED=true

That’s it - z4j spawns z4j-scheduler at startup and supervises it (bounded auto-restart, graceful SIGTERM). One container, no extra ops surface.

For production, run z4j-scheduler as a separate process or container. Multiple instances elect a leader via Postgres advisory lock; only the leader ticks. Followers stay warm.

Terminal window
pip install z4j-scheduler
z4j-scheduler serve \
--brain-grpc-url brain.internal:7701 \
--brain-rest-url https://brain.internal \
--tls-cert /etc/z4j/scheduler.crt \
--tls-key /etc/z4j/scheduler.key \
--tls-ca /etc/z4j/ca.crt

z4j side enables the gRPC server with:

Z4J_SCHEDULER_GRPC_ENABLED=true
Z4J_SCHEDULER_GRPC_ALLOWED_CNS=["scheduler-prod","scheduler-staging"]

Mutual TLS is required: z4j’s gRPC server presents its server cert; the scheduler presents a client cert whose CN must be in the allow-list. There is no plaintext fallback.

You can put schedules into z4j-scheduler’s database three ways:

The Schedules page (per-project) has a real UI: name, engine, kind (cron / interval / one_shot / solar), expression, task name, args, kwargs, queue, catch_up policy. Audit row written on every change.

2. Declarative (in your app’s startup hook)

Section titled “2. Declarative (in your app’s startup hook)”

Commit your schedules in source. Reconciler posts the dict to brain on app startup; same shape across django, flask, fastapi:

from z4j_scheduler.declarative import ScheduleSpec, reconcile
await reconcile(
schedules=[
ScheduleSpec(
name="hourly-cleanup",
engine="celery",
kind="cron",
expression="0 * * * *",
task_name="myapp.tasks.cleanup",
),
],
project="my-app",
source="declarative",
brain_url="http://brain:7700",
api_token=settings.Z4J_BRAIN_API_TOKEN,
)

Re-running reconcile with the same dict is a no-op; only diffs land in the audit log.

Import from any existing scheduler:

Terminal window
# Celery beat schedule -> z4j
z4j-scheduler import --from celery --celery-app myapp:app \
--project myproject --brain-url https://brain.internal
# rq-scheduler -> z4j
z4j-scheduler import --from rq --redis-url redis://... \
--project myproject --brain-url https://brain.internal
# APScheduler 3.x or 4.x -> z4j
z4j-scheduler import --from apscheduler --jobstore-url postgresql://... \
--project myproject --brain-url https://brain.internal
# system crontab -> z4j
z4j-scheduler import --from cron --crontab /etc/crontab \
--project myproject --brain-url https://brain.internal

Round-trip: every importer pairs with an exporter. z4j-scheduler export --to celery --celery-app myapp:app renders a Python beat_schedule file you can drop back into Celery if you ever decide to leave z4j.

Per-schedule field that decides what happens when the scheduler was down longer than the schedule’s interval:

  • skip - do nothing on recovery; the next regular tick fires once.
  • fire_one_missed - fire once on recovery, then resume normal cadence. Right for “nightly report” semantics.
  • fire_all_missed - fire once for every missed slot. Right for “every-5-minute metric backfill” semantics. Capped at 1000 fires per recovery to prevent runaways.

Default: skip (the safest choice for most schedules).

Terminal window
# 1. Install (no removal yet - both run side-by-side)
pip install z4j-scheduler
# 2. Import existing schedules into z4j
z4j-scheduler import --from celery --celery-app myapp:app \
--project myproject --brain-url https://brain.internal
# 3. Verify in dashboard. Open the Schedules page; confirm everything
# landed and is firing as expected.
# 4. Disable celery-beat (stop the daemon, remove from supervisor)
systemctl stop celery-beat
# or: docker compose stop celery-beat
# 5. Run z4j-scheduler instead (embedded or standalone - your choice)
z4j-scheduler serve --brain-url ...
# OR set Z4J_EMBEDDED_SCHEDULER=true on brain
# 6. (Optional) Uninstall celery-beat
pip uninstall celery-beat django-celery-beat

Coexistence with celery-beat - gradual migration

Section titled “Coexistence with celery-beat - gradual migration”

If you can’t stop celery-beat in one shot (e.g., shared Postgres schedule table with another team’s app), keep both running and use z4j-celerybeat as the coexistence adapter:

  • celery-beat continues firing its schedules.
  • z4j-celerybeat surfaces those celery-beat schedules in the z4j dashboard - read AND write (when django-celery-beat is installed).
  • z4j-scheduler can fire its OWN schedules alongside (different rows in z4j’s database).
  • Your dashboard shows both: celery-beat managed schedules with a source tag, z4j-managed schedules without.

When you’re ready to fully migrate, run the importer and disable celery-beat.

  • You only have one engine and one schedule. A single crontab line is simpler.
  • You explicitly want celery-beat’s exact behavior (e.g., a custom celery-beat scheduler class your team wrote). z4j-celerybeat surfaces that scheduler’s existing schedules; z4j-scheduler replaces it.
  • You’ve committed to APScheduler 4’s persistence model. That model is in-process; z4j-scheduler is out-of-process. Different shape, different trade-offs.

Last updated: