Skip to content

Background Tasks

The portal runs recurring and deferred work through a background task runner built on Procrastinate. This replaces the Kubernetes CronJobs previously used for scheduled maintenance, keeping the scheduling logic inside the application and its database.

Why Procrastinate

Procrastinate is a PostgreSQL-based task queue: it uses the portal's existing Postgres database as the broker, so there is no separate message broker (such as Redis or RabbitMQ) to run and operate. Tasks, their state, retries, and the periodic schedule all live in Postgres.

This means the task runner requires Postgres. The SQLite development fallback cannot run the worker, because Procrastinate's schema is raw Postgres SQL. On SQLite the Procrastinate migrations are skipped and no worker runs.

Anatomy of a task

Tasks live in servala/core/tasks.py. A periodic task combines two decorators:

@app.periodic(cron=settings.TASK_CLEANUP_EXPIRED_ORGANIZATIONS_CRON)
@app.task(name="core.cleanup_expired_organizations", retry=RETRY)
def cleanup_expired_organizations(timestamp=None):
    call_command("cleanup_expired_organizations")
  • @app.task(name=...) registers the task under a stable name.
  • @app.periodic(cron=...) tells the worker to schedule it on a cron expression.
  • The timestamp=None argument is required. The periodic scheduler passes a timestamp when it fires the task, but a manual .defer() call does not, so the argument must stay optional. Omitting the default breaks manual deferral.

Most tasks are thin wrappers that call an existing Django management command, so the same logic can be run manually (for example with --dry-run) and on a schedule.

Retries

All periodic tasks share a retry strategy that mirrors the restartPolicy: OnFailure behaviour of the CronJobs they replaced: up to 5 attempts with exponential backoff (roughly 10s, 100s, 17min, 2.8h). Transient failures are retried rather than skipped until the next scheduled run.

Deferring work

Application code can queue asynchronous work outside the periodic schedule by deferring a task:

my_task.defer(some_argument=123)

The worker picks it up and runs it. This is the mechanism for offloading slow or retriable work from a web request.

Running the worker

The worker is a long-running process:

just worker
# equivalent to: uv run python manage.py procrastinate worker

In production the worker runs as a sidecar container in the portal pod, so it shares the portal's image, configuration, and database connection and scales alongside it. A single worker both executes deferred tasks and schedules the periodic ones; no separate scheduler process is needed.

Logging is required

Procrastinate logs through Python's logging. Without an explicit logging configuration, worker activity and task failures are invisible. The portal configures a console handler for the procrastinate logger in settings so that worker output and failures are surfaced.

The periodic tasks

The portal currently schedules three periodic tasks. Their cron schedules come from settings and can be overridden per environment with the environment variables below (all times are UTC).

Task Default cron Env override Purpose
core.clear_sessions 42 2 * * 1 SERVALA_TASK_CLEAR_SESSIONS_CRON Weekly cleanup of expired Django sessions.
core.send_expiration_reminders 42 6 * * * SERVALA_TASK_EXPIRATION_REMINDERS_CRON Daily trial expiration reminder emails.
core.cleanup_expired_organizations 0 * * * * SERVALA_TASK_CLEANUP_EXPIRED_ORGANIZATIONS_CRON Hourly pause and teardown of expired trial organizations.

The cleanup task runs hourly rather than daily so that pause and teardown land close to the humanised trial action moment (SERVALA_TRIAL_ACTION_HOUR); a once-a-day schedule could fire up to a day late.

Testing

The test suite does not run a real worker or Postgres queue. An autouse fixture provides an in-memory connector, so deferred tasks can be asserted on without a running worker.