{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreidzuihi2r5sxgdebwlln7wittbdm342igqezcjyqas364fm3hckhy",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mow2usmbetd2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreigx6fjl6akrpbbixu4xywpbexd3i34jipsmh4s73bewxzxjerabge"
},
"mimeType": "image/webp",
"size": 62562
},
"path": "/martin_palopoli/cron-jobs-without-celery-redis-or-beat-how-fitz-puts-a-distributed-scheduler-inside-the-language-57if",
"publishedAt": "2026-06-22T23:10:13.000Z",
"site": "https://dev.to",
"tags": [
"webdev",
"python",
"opensource",
"programming",
"github.com/Thegreekman76/fitz",
"docs/guide.md",
"@celery_app.task",
"@app.post",
"@cron",
"@background",
"@post"
],
"textContent": "> To run a task every 5 minutes in Python you need Celery + Redis + Celery Beat + a worker process + a dedicated Dockerfile. In Fitz it's a decorator. With retry, timezone, persistence, and catch-up inside the language.\n\n## The same story every time\n\nYour client is happy with the API. It's running. Then they ask for three things:\n\n 1. \"Can we clean up expired sessions every night?\"\n 2. \"Can the welcome email go out in the background so signup doesn't wait?\"\n 3. \"The daily report needs to run at 9 AM Buenos Aires time.\"\n\n\n\nThree small asks. If you're in Python, the honest answer is: \"OK, give me two days to add Celery.\"\n\n### The typical Python stack\n\n\n pip install celery redis\n\n\n`celery_app.py`:\n\n\n\n from celery import Celery\n from celery.schedules import crontab\n\n celery_app = Celery(\n \"myapp\",\n broker=\"redis://redis:6379/0\",\n backend=\"redis://redis:6379/1\",\n )\n\n celery_app.conf.beat_schedule = {\n \"cleanup-sessions-nightly\": {\n \"task\": \"myapp.tasks.cleanup_old_sessions\",\n \"schedule\": crontab(hour=3, minute=0),\n },\n \"daily-report\": {\n \"task\": \"myapp.tasks.generate_daily_report\",\n \"schedule\": crontab(hour=12, minute=0), # 9am Buenos Aires = 12:00 UTC\n # heads up: if DST shifts, this fires at the wrong time\n },\n }\n celery_app.conf.timezone = \"UTC\"\n\n\n`tasks.py`:\n\n\n\n from .celery_app import celery_app\n from .db import get_session\n\n @celery_app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3)\n def cleanup_old_sessions():\n with get_session() as s:\n s.execute(\"DELETE FROM sessions WHERE expires_at < now()\")\n s.commit()\n\n @celery_app.task(autoretry_for=(SMTPError,), retry_backoff=True, max_retries=5)\n def send_welcome_email(email: str):\n smtp.send(email, \"Welcome!\")\n\n\n`api.py`:\n\n\n\n @app.post(\"/signup\")\n def signup(creds: Credentials):\n user = create_user(creds)\n send_welcome_email.delay(user.email) # fire-and-forget via broker\n return user\n\n\n`docker-compose.yml`:\n\n\n\n services:\n api:\n build: .\n command: uvicorn api:app --host 0.0.0.0\n celery-worker:\n build: .\n command: celery -A celery_app worker --loglevel=info\n depends_on: [redis]\n celery-beat:\n build: .\n command: celery -A celery_app beat --loglevel=info\n depends_on: [redis]\n redis:\n image: redis:7-alpine\n\n\nPlus:\n\n * A `supervisord.conf` or systemd unit to keep things alive when they crash.\n * (Optional) `flower` for visibility — a 4th process.\n * Timezone conversions done by hand (Celery beat works in UTC, you have to compute the offset).\n * When a worker crashes between hitting the broker and finishing the task: does it retry? Does it hang? Depends on how you configured `acks_late` and `task_reject_on_worker_lost`.\n\n\n\nFour processes in production. Three new libraries. A broker. New conventions. A day of setup.\n\n## The same thing in Fitz\n\n\n @cron(\"0 3 * * *\", tz=\"UTC\")\n async fn cleanup_old_sessions(db: DbConn) {\n db.exec(\"DELETE FROM sessions WHERE expires_at < now()\").await\n }\n\n @cron(\"0 9 * * *\", tz=\"America/Argentina/Buenos_Aires\")\n async fn daily_report(db: DbConn) {\n // ...\n }\n\n @background\n async fn send_welcome_email(email: Str) {\n // expensive thing\n }\n\n @post(\"/signup\")\n fn signup(creds: Credentials) -> User {\n let user = create_user(creds)\n spawn(send_welcome_email(user.email)) // typed fire-and-forget\n return user\n }\n\n\n`docker-compose.yml`:\n\n\n\n services:\n api:\n build: .\n\n\nThat's it. One binary. One process. No broker. No dedicated worker. No beat. The scheduler runs inside the Fitz process.\n\n## The raw table\n\nItem | Python (Celery + Redis + Beat) | Fitz\n---|---|---\nInitial setup | 4 files + 3 services + 1 broker | 1 decorator\nSchedule | `crontab(hour=3, minute=0)` in config | `@cron(\"0 3 * * *\")`\nTimezone | UTC + manual offset | `tz=\"America/Argentina/Buenos_Aires\"`\nRetry/backoff | `autoretry_for=(...)` + `retry_backoff=True` | `retry={max=3, backoff=\"exponential\", initial_secs=1, max_secs=30}`\nRun persistence | Redis result backend + viewing via Flower | `store=db`, table auto-created in Postgres\nCatch-up of missed runs | Not native | `catch_up=true`\nBackground fire-and-forget | `.delay(arg)` via broker | `spawn(fn_call)` direct\nType-checked args | None (everything JSON-serialized) | Static —`spawn` requires `@background` and refines to `Future<T>`\nProduction processes | api + worker + beat + redis | api\nDocker images | 3 (api, worker, beat) + redis | 1\n\n## Opt-in persistence with `store=db`\n\nWhen you want run history for auditing:\n\n\n\n @cron(\"0 3 * * *\", store=db, retry={max=3, backoff=\"exponential\"})\n async fn cleanup_old_sessions(db: DbConn) {\n db.exec(\"DELETE FROM sessions WHERE expires_at < now()\").await\n }\n\n\nOn boot, Fitz creates (if missing) `fitz_cron_jobs` and `fitz_cron_runs`. Each attempt is persisted with `started_at`/`finished_at`/`status`/`attempt`/`error`. You query with plain SQL:\n\n\n\n SELECT job_name, status, attempt, error, started_at\n FROM fitz_cron_runs\n WHERE started_at > now() - interval '24 hours'\n ORDER BY started_at DESC;\n\n\nNo Flower install. No Sentry webhook. The DB you already have.\n\n### Catch-up\n\nIf the binary was down between 3 AM and 7 AM and the cron was at 3 AM:\n\n * **Celery beat** : the run is lost. The task won't fire again until the next midnight.\n * **Fitz with`catch_up=true`**: on boot, it sees there was a missed run between `last_run_at` and `now`, fires ONE immediate run (not N — avoids spam), then resumes the normal schedule.\n\n\n\n\n @cron(\"0 3 * * *\", store=db, catch_up=true)\n async fn cleanup_old_sessions(db: DbConn) { ... }\n\n\n## Configurable retry with backoff\n\nThree backoff modes: `exponential` (1s, 2s, 4s, 8s...), `linear` (1s, 2s, 3s, 4s...), `constant` (1s, 1s, 1s...). With cap `max_secs`:\n\n\n\n @cron(\"*/10 * * * *\",\n retry={ max=5, backoff=\"exponential\", initial_secs=1, max_secs=60 })\n async fn sync_external_api(db: DbConn) {\n // 1s → 2s → 4s → 8s → 16s (capped at 60s if it would go higher)\n }\n\n\nIn Python with Celery:\n\n\n\n @celery_app.task(\n autoretry_for=(ConnectionError, TimeoutError),\n retry_backoff=True,\n retry_backoff_max=60,\n retry_jitter=False,\n max_retries=5,\n )\n def sync_external_api():\n ...\n\n\nEquivalent. But notice Celery requires you to enumerate the exceptions that trigger retry, and backoff is a boolean instead of a kind enum. Fitz retries on **any** error the fn returns (consistent with the language's Result model), and the backoff mode is explicit.\n\n## Real timezone, not hardcoded offsets\n\nDST changes are the bug that wakes you up at 4 AM. Celery beat works in UTC and you have to remember that between March and November your 9 AM Buenos Aires cron runs at 12 UTC, but the rest of the year it shifts. Your client is in another timezone. Your app sells in three more countries.\n\nFitz accepts IANA timezones directly:\n\n\n\n @cron(\"0 9 * * *\", tz=\"America/Argentina/Buenos_Aires\") async fn buenos_aires() { ... }\n @cron(\"0 9 * * *\", tz=\"America/New_York\") async fn new_york() { ... }\n @cron(\"0 9 * * *\", tz=\"Asia/Tokyo\") async fn tokyo() { ... }\n\n\nEach runs at 9 AM **local** time in its tz, with DST handled by the underlying lib (`chrono-tz`). No manual conversions.\n\n## Background jobs without a broker\n\n\n # Python\n @app.post(\"/signup\")\n def signup(creds: Credentials):\n user = create_user(creds)\n send_welcome_email.delay(user.email) # serialize args → Redis → worker\n return user\n\n\n\n // Fitz\n @background\n async fn send_welcome_email(email: Str) {\n smtp.send(email, \"Welcome!\")\n }\n\n @post(\"/signup\")\n fn signup(creds: Credentials) -> User {\n let user = create_user(creds)\n spawn(send_welcome_email(user.email)) // native tokio::spawn, typed\n return user\n }\n\n\nThe compiler requires the fn be decorated with `@background` to authorize the `spawn(...)` — without it, the callsite throws a type error at build time. And it refines the return to `Future<Null>` with the concrete type of the target, not `Any`. If `send_welcome_email` returns `Result<()>`, the `spawn(...)` gives it to you typed.\n\nWhen NOT to use `@background` and go back to Celery?\n\n * If you need jobs to survive process crashes → explicit persistence with `store=db` covers cron jobs; for `@background` with persistence, that's residual debt.\n * If you need to distribute jobs across N workers on N nodes → Celery with a shared broker is still the answer. `@background` runs in-process.\n\n\n\nFor 90% of services (nightly cleanup, transactional email, KPI recompute, cache sync) Fitz's model is enough and then some.\n\n## Cron-only mode for systemd\n\nFor services that ONLY have jobs (no HTTP), `fitz build` produces a binary that starts the scheduler and blocks on ctrl+c:\n\n\n\n @cron(\"0 3 * * *\") async fn cleanup() { ... }\n @cron(\"*/15 * * * *\") async fn sync() { ... }\n\n\nAs a systemd unit:\n\n\n\n [Unit]\n Description=Scheduled jobs for myapp\n After=network.target\n\n [Service]\n ExecStart=/usr/local/bin/myapp-jobs\n Restart=always\n User=myapp\n\n [Install]\n WantedBy=multi-user.target\n\n\nZero brokers. The binary is 5 MB. `systemctl restart myapp-jobs` and you're done.\n\n## Bit-for-bit parity `fitz run` ↔ `fitz build`\n\nThis is what makes it shippable: the binary produced by `fitz build` runs the same scheduler as `fitz run`, with the same cron expression parser, the same retries, the same timezone. Same syntax, same semantics, no \"oh, this only works in production if you configure Celery.\"\n\n## What Fitz does NOT give you (yet)\n\nBeing honest about where it doesn't reach:\n\n * **Distributing jobs across N workers/nodes sharing a queue**. `@cron` runs in-process. If you run two binaries with the same `@cron`, both fire — bug, not feature. For horizontal scaling of jobs, it's still Celery (or NATS JetStream, or Temporal). There's explicit debt in the roadmap about distributed locks.\n * **A Flower-like UI**. The data is in `fitz_cron_jobs` and `fitz_cron_runs`. If you want dashboards, external dashboards (Grafana, Metabase) cover it.\n * **`@background` with persistence across restarts**. For `@cron` it's in v0.11.2 (close of 9.w.3.iter2). For `@background` the spawn fires but doesn't survive crashes — deferred to iter3 if demand appears.\n * **In-flight job cancellation**. Today there's no API to \"cancel all queued runs of X job\". Process dies → in-flight runs die with it.\n\n\n\nIf you're in one of those cases, Fitz isn't the tool today. If not, the one-binary model covers the real case with fewer moving parts.\n\n## Closing\n\nThe argument for adding Celery to a Python API is typically: \"but later it scales better.\" In 90% of services I've shipped over the past decade, that \"later\" never came — the app spent its entire lifetime with fewer than 100 jobs per hour and never needed a worker cluster.\n\nFor that 90%, Fitz replaces 4 processes + 3 libraries + 1 broker with one decorator. When you reach the other 10% where you need real horizontal scaling, Celery is still there — `from python import celery` is also available, you can do it yourself.\n\nBut starting the project with the simplest possible model and raising complexity only when you need it is the feedback loop Fitz wants to give you.\n\n**Next post in the series** : **\"Auth with JWT, RBAC, and token blacklist without gluing 5 libraries: Fitz vs FastAPI + python-jose + passlib + Redis blacklist\"** — the full auth flow, side by side.\n\n**Repo** : github.com/Thegreekman76/fitz\n**Chapter 30 of the guide** (Jobs without Celery): docs/guide.md",
"title": "Cron jobs without Celery, Redis, or Beat: how Fitz puts a distributed scheduler inside the language"
}