Skip to content

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.

Drop a YAML data file in your module’s data/ directory with a data_type: CronJob record.

modules/invoicing/data/cron_jobs.yaml
- data_type: CronJob
identifier: cron_recurring_invoices
name: Generate Recurring Invoices
model: RecurringInvoice
method: cron_generate_invoices
frequency: 1
frequency_unit: Days
active: true
FieldTypeMeaning
identifierCharStable, unique handle for the record (snake_case).
nameCharHuman-readable label shown in the UI.
modelManyToOne (ModelRegistry)The model whose method runs. Reference it by class name (e.g. RecurringInvoice).
methodCharName of the method to call on that model (see the contract below).
frequencyIntegerNumber of frequency_units between runs.
frequency_unitSelectionOne of Minutes, Hours, Days, Months, Years (default Minutes).
activeBooleanWhen 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 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.

  • Runs as the bot / system user. Before each job the scheduler elevates privileges and sets the environment user to the system bot (User id 1). 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 status to Running while it executes, records last_run on success, and resets status back to Scheduled whether the job succeeds or raises.
  • Failures are logged, not retried automatically. If the method raises, the error is logged and status is restored to Scheduled; the job runs again on its next due window.

The scheduler is a separate worker process (started by the server’s job runner). Its loop:

  1. A polling tick fires on a fixed interval (every 10 seconds) and runs roughly once a second.
  2. On each poll it enumerates every Postgres database that has a cronjob table, initialising the ORM for newly-discovered databases.
  3. For each database it selects active CronJob rows whose next_run is in the past (or null) and whose status is Scheduled, 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 with NX (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 finally block 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.

KeyDefaultMeaning
CRON_WORKERS1Number 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_PORTlocalhost / 6379Valkey connection used for the cross-worker job locks.

The outbound message queue is drained by a cron job — a one-minute Message.cron_flush:

modules/core/data/email_queue_cron.yaml
- data_type: CronJob
identifier: cron_email_queue_processor
name: Process Outbound Messages
model: Message
method: cron_flush
frequency: 1
frequency_unit: Minutes
active: true

See the Email page for what cron_flush does and how messages get queued.