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_invoices name: Generate Recurring Invoices model: RecurringInvoice method: cron_generate_invoices 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. RecurringInvoice). |
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.
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 RecurringInvoice(Model): _verbose_name = "Recurring Invoice"
async def cron_generate_invoices(cls): """Entry point for the cron job. No business args — it finds its own work.""" due = await cls.filter(next_invoice_date__lte=date.today(), active=True).all() for template in due: await template.generate_invoice()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:
- A polling tick fires on a fixed interval (every 10 seconds) and runs roughly once a second.
- On each poll 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.
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.
The same Valkey instance also carries a module_operation_in_progress:<db_name> flag —
while a module install/upgrade is running, cron jobs for that database are skipped so they
don’t race a migration.
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 Email Queue
Section titled “A Real Example: Flushing the Email Queue”The outbound message queue is drained by a cron job — a one-minute Message.cron_flush:
- data_type: CronJob identifier: cron_email_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.