Jobs on a timetable
Task Scheduling
The scheduler enqueues registered background jobs at the right time. It never runs work itself — the job queue owns retries, timeouts, logging, and shutdown. Schedules are in memory and best-effort: an occurrence missed while the app is down is not replayed.
Defining the schedule
Put it in src/lib/schedule.py. main.py imports it at startup and runs the scheduler only when at least one task is defined.
# src/lib/schedule.py
from casp.schedule import schedule
from src.lib.jobs import cleanup_sessions, refresh_index, send_summary
schedule.every(minutes=5).job(refresh_index)
schedule.daily_at("08:30").job(send_summary, locale="en")
schedule.daily_at("02:00", utc=True).job("backups.run")
schedule.cron("0 */2 * * *").job(cleanup_sessions)
schedule.every(seconds=30, overlap=True).job("heartbeat", name="heartbeat")
| Form | Fires |
|---|---|
| every(seconds=, minutes=, hours=, days=) | Repeatedly; the first run is one interval after startup |
| daily_at("HH:MM") | Every day at that time in APP_TIMEZONE, following daylight saving |
| daily_at("HH:MM", utc=True) | Every day at that UTC time |
| cron("m h dom mon dow") | Standard five-field cron in APP_TIMEZONE (or utc=True) |
| once(datetime) | Once; an instant already in the past fires promptly |
-
.job(target, name=None, **payload)takes a@jobfunction or a job name plus a JSON-serializable payload. Targeting an unregistered job fails at startup. -
Cron supports
*, lists, ranges, steps (*/15), and@hourly,@daily,@weekly,@monthly,@yearly. Sunday is 0 or 7. -
Use
daily_atfor business-local times andutc=Truefor infrastructure work.
Overlap, backpressure, and missed runs
-
A recurring task skips an occurrence while its previous job is still queued, running, or retrying. Pass
overlap=Trueonly when concurrent runs are safe. - A full job queue skips the occurrence instead of waiting.
- After a stall, occurrences are coalesced — scheduled from now, never replayed in a burst.
- Nothing is caught up after downtime.
Where it runs
One process per machine runs the schedule: the first to create .casp/scheduler.lock holds it and
refreshes it every 15 seconds while the others stand by. A lock not refreshed for 60 seconds is abandoned, so after a crash
the schedule resumes within about a minute.
The lock is a file. Separate machines or containers each run their own copy — set
SCHEDULER=off on every replica except one, or run scheduling in a dedicated process.
Shutdown, observability, and testing
The scheduler starts after the job workers and stops before they drain, so a timer can never enqueue into a closing queue.
It never runs during static export. Dispatches, overlap skips, queue-full skips, and failures are logged with target
schedule (never the payload), and schedule.metrics() returns
due, dispatched, skipped_overlap,
skipped_full, and failed.
To test timing without waiting, build a separate Scheduler(queue) with a stub queue and call
await scheduler.tick(now) with explicit datetimes. Trigger classes expose next_after(datetime).
Background Jobs
Define the jobs a schedule dispatches, with retries and durable storage.