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_entries
name: Generate Recurring Entries
model: RecurringEntry
method: cron_generate_entries
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. RecurringEntry).
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.

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 identifier and 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 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.

  • 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. 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.
  2. On each sweep 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.

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 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.

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.

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.

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:

modules/core/data/message_queue_cron.yaml
- data_type: CronJob
identifier: cron_message_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.