Cron / Scheduled Jobs
Fullfinity runs background work through CronJob records. A cron job names a model and a
method; the scheduler polls every database for due jobs and calls that method on a schedule.
There is no Python decorator for scheduling — a job is data: you ship a CronJob YAML
record in your module’s data/ directory, and it is picked up automatically.
Defining a Cron Job (YAML)
Section titled “Defining a Cron Job (YAML)”Drop a YAML data file in your module’s data/ directory with a data_type: CronJob record.
- data_type: CronJob identifier: cron_recurring_entries name: Generate Recurring Entries model: RecurringEntry method: cron_generate_entries frequency: 1 frequency_unit: Days active: trueFields
Section titled “Fields”| Field | Type | Meaning |
|---|---|---|
identifier | Char | Stable, unique handle for the record (snake_case). |
name | Char | Human-readable label shown in the UI. |
model | ManyToOne (ModelRegistry) | The model whose method runs. Reference it by class name (e.g. RecurringEntry). |
method | Char | Name of the method to call on that model (see the contract below). |
frequency | Integer | Number of frequency_units between runs. |
frequency_unit | Selection | One of Minutes, Hours, Days, Months, Years (default Minutes). |
active | Boolean | When false, the job is skipped. Defaults to true. |
The model also has read-only status (Scheduled / Running), next_run, last_run and
module fields — these are maintained by the scheduler and you don’t set them in YAML.
Lifecycle: a cron is owned by the module that declares it
Section titled “Lifecycle: a cron is owned by the module that declares it”When your module imports a CronJob record, it is stamped as owned by that module. That
ownership drives cleanup, so a cron never outlives the YAML that defines it:
- Rename or remove a cron from your YAML, then upgrade — the old owned record is deleted on
the next import. (Rename the
identifierand you get the new cron without the old one lingering and double-firing alongside it.) - Uninstall the module — every cron it owns is removed.
- Crons you create in the UI have no owning module and are never touched by this cleanup — only module-declared crons are pruned.
Ownership is not overwritten when another module re-declares the same identifier (a cron
override): the original owner keeps it. Ownership only transfers when the original owner stops
declaring the cron and another module declares it in the same upgrade — then it is handed off to the
new module rather than deleted.
next_run is computed from last_run + frequency × frequency_unit. A job whose next_run
is null (never run yet) is treated as due immediately on the next poll. Months and Years
are approximated as 30 and 365 days respectively.
The Method Contract
Section titled “The Method Contract”The method you name must be a classmethod on the named model, callable with no business
arguments. The scheduler invokes it as await Model.method() — nothing is passed in.
In Fullfinity a method is auto-promoted to a classmethod when its first parameter is named
cls, so a cron entry point looks like this:
class RecurringEntry(Model): _verbose_name = "Recurring Entry"
async def cron_generate_entries(cls): """Entry point for the cron job. No business args — it finds its own work.""" due = await cls.filter(next_date__lte=date.today(), state="Active").all() for template in due: await template.generate()The job is expected to find its own work (query the records it needs, iterate, act). Because nothing is passed in, the method is responsible for scoping, batching, and error handling for the records it processes.
Execution Context
Section titled “Execution Context”- Runs as the bot / system user. Before each job the scheduler elevates privileges and
sets the environment user to the system bot (
Userid1). Code in the job therefore runs with full access; if you need to act as a specific user, switch context explicitly inside the method. - One transaction per job. Each job runs inside its own database transaction. The
scheduler flips
statustoRunningwhile it executes, recordslast_runon success, and resetsstatusback toScheduledwhether the job succeeds or raises. - Failures are logged, not retried automatically. If the method raises, the error is
logged and
statusis restored toScheduled; the job runs again on its next due window.
The Scheduler
Section titled “The Scheduler”The scheduler is a separate worker process (started by the server’s job runner). Its loop:
- It runs a sweep, then waits ~10 seconds before the next one — a sequential loop, so a sweep that occasionally runs long simply delays the next one rather than overlapping it.
- On each sweep it enumerates every Postgres database that has a
cronjobtable, initialising the ORM for newly-discovered databases. - For each database it selects active
CronJobrows whosenext_runis in the past (or null) and whosestatusisScheduled, and spawns each as an async task. Concurrency is capped (up to 20 jobs in flight at once).
So a job with frequency: 1, frequency_unit: Minutes becomes eligible roughly once a minute;
the 10-second poll is the granularity at which due jobs are noticed, not the run frequency.
Each database is polled in isolation, so one bad database never starves the others. A database
that is still being created, is being dropped, or is half-migrated — its schema predates a
model change and is awaiting -u all, so a table or column the code expects is missing — is
skipped quietly and re-validated on a later sweep, once it has settled or been upgraded. This is
an expected operational state, not an error, so it is not logged as one; run the upgrade to bring
the database current and its crons resume automatically.
Cross-Worker Locking (runs once across workers)
Section titled “Cross-Worker Locking (runs once across workers)”You can run more than one scheduler worker, and the same database is polled by all of them. To guarantee a job fires once rather than once per worker, the scheduler takes a Valkey lock before running each job:
- Lock key:
cron_job_lock:<db_name>:<job_id>, set withNX(only if absent) and a 1-hour TTL. - If the lock is already held (another worker grabbed it, or a previous run is still going), the job is skipped on this worker.
- The lock is released in a
finallyblock when the job finishes.
While a module install/upgrade/uninstall is running, cron jobs for that database are held off so they don’t race the migration: the operation takes a database-scoped exclusive advisory lock, and each job takes a shared one for its run. Shared holders coexist (jobs still run concurrently with each other) but conflict with the exclusive lock, so a job that starts during an operation simply skips and reruns on the next sweep. The lock is released when the operation’s transaction settles, so a crash can never leave crons wedged.
Configuration
Section titled “Configuration”| Key | Default | Meaning |
|---|---|---|
CRON_WORKERS | 1 | Number of scheduler worker tasks to spawn. Set to 0 to disable the scheduler entirely (the shipped config.yaml ships it disabled — enable it in your deployment). |
VALKEY_HOST / VALKEY_PORT | localhost / 6379 | Valkey connection used for the cross-worker job locks. |
A Real Example: Flushing the Message Queue
Section titled “A Real Example: Flushing the Message Queue”The outbound message queue is drained by a cron job — a one-minute Message.cron_flush:
- data_type: CronJob identifier: cron_message_queue_processor name: Process Outbound Messages model: Message method: cron_flush frequency: 1 frequency_unit: Minutes active: trueSee the Email page for what cron_flush does and how messages get queued.