Async Tasks (Celery)
Asynchronous Background Tasks
DigitVA uses Celery with Redis for background task execution and Celery Beat backed by PostgreSQL for periodic scheduling.
Architecture
| Component | Container | Role |
|---|---|---|
| Redis | minerva_redis_service |
Broker and result backend |
| Celery worker | minerva_celery_worker |
Executes tasks from the queue |
| Celery Beat | minerva_celery_beat |
Enqueues periodic tasks on schedule |
Beat uses sqlalchemy_celery_beat.schedulers:DatabaseScheduler. The schedule is stored in the minerva PostgreSQL database, so interval changes take effect without restarting Beat.
Flask App Context
celery_init_app() in app/__init__.py wraps every task in flask_app.app_context() via a custom FlaskTask class. Tasks have full access to db.session, Flask config, and all app utilities.
Configuration (config.py)
CELERY = {
"broker_url": "redis://minerva_redis_service:6379/0",
"result_backend": "redis://minerva_redis_service:6379/0",
"beat_dburi": "postgresql://minerva:minerva@minerva_db_service:5432/minerva",
"task_ignore_result": True,
"timezone": "UTC",
"enable_utc": True,
}
Startup Sequence (make_celery.py)
On worker/beat startup make_celery.py:
- Creates the Flask app via
create_app(); beat without blueprints (DIGITVA_REGISTER_ROUTES=0, set byscripts/wait-for-celery-beat-db.sh; runtime-and-operations.md, "Process Memory") - Retrieves
celery_appfromflask_app.extensions["celery"] - Imports
app.tasks.sync_tasksto register tasks with the worker - Calls
cleanup_stale_runs()— marks orphanedrunningsync rows (older than 45 minutes) aserror - Calls
ensure_sync_scheduled()— idempotently seeds the ODK sync beat entry - Calls
ensure_coding_timeout_cleanup_scheduled()— seeds stale allocation cleanup - Calls
ensure_submission_analytics_mv_refresh_scheduled()— seeds hourly analytics MV refresh
With PUBLIC_DEMO_ENABLED (the demo deployment, docs/policy/public-demo.md
section 8a) step 5 onward is replaced by app/tasks/beat_schedule.py
(seed_demo_beat_schedule): it seeds only the SmartVA pending sweep (not the
bundle applier), the coding allocation and reviewer-eligible sweep, the web
intake upload purge, the notification purge, the sign-in IP wipe and the
coding search telemetry prune, the nightly database backup (the demo
database is backed up, owner 2026-10-22) and the daily public demo purge, then disables every other enabled
celery_periodictask row (disable_off_allowlist, matched by task path;
rows are kept). ODK sync, KPI and analytics refreshes, TLS checks,
the organization demo reset, the training demo cleanup and the S3 upload
sweep are not scheduled. The demo also runs Celery on queue demo with
global_keyprefix demo: on the broker and result backend. Flag off:
unchanged. seed_demo_beat_schedule and disable_off_allowlist do nothing
(one logged error) when va_project_master holds a project other than the
demo's, so a demo pointed at the main database cannot disable its beat rows.
Registered Tasks
app.tasks.public_demo_tasks.purge_public_demo_task
Defined in app/tasks/public_demo_tasks.py;
beat row "Public demo purge — daily" (86 400 seconds, seeded by
ensure_public_demo_purge_scheduled, on the demo allowlist only). Calls
public_demo_purge_service.purge_run(apply=True): deletes public-demo
accounts idle PUBLIC_DEMO_PURGE_IDLE_DAYS (30) and everything they created,
one account per transaction, at most PUBLIC_DEMO_PURGE_BATCH per run
(docs/current-state/authentication-and-onboarding.md section 2.5), then the
demo project's SmartVA run folders older than that (disk and object store;
rows deleted unless a va_smartva_runs row references them). Returns
{status, accounts, failed}; status is ok, or not_enabled /
purge_disabled / main_database when a guard refused and nothing was
touched (PUBLIC_DEMO_PURGE_ENABLED is off by default). Never raises. Logs
and the public_demo_purge security event carry counts only. The worker
registers the task because app/tasks/beat_schedule.py imports the module at
demo startup.
app.tasks.sync_tasks.run_odk_sync
Defined in app/tasks/sync_tasks.py.
Purpose: Run the full ODK data sync and record the outcome in va_sync_runs.
Signature:
run_odk_sync(triggered_by="scheduled", user_id=None)
Behavior:
- Writes a
VaSyncRunrow withstatus="running"and commits immediately (dashboard sees it) - Calls
cleanup_stale_runs()— expires orphaned "running" rows before starting - Calls
va_data_sync_odkcentral()— incremental per-form pipeline (delta check → gap sync → OData fetch → upsert → language normalization → canonical repair-task enqueue → per-form commit) - On success: updates with
status="success"and metric counts - On partial success (some forms failed): updates with
status="partial"and lists failed form IDs inerror_message - On error: updates with
status="error"anderror_message, then re-raises so Celery marks the task failed
Limits: soft_time_limit=1800s, time_limit=3600s
Default schedule: every 6 hours via Celery Beat (seeded by ensure_sync_scheduled())
Admin dashboard behavior:
- the sync dashboard can revoke a running sync task from the UI
- revoked runs are recorded in
va_sync_runswith statuscancelled - ODK coverage checks from the dashboard are loaded on demand, not automatically on panel render
- sync status now includes shared ODK connection alerts so operators can see active cooldowns and recent retryable connection failures without triggering more live ODK calls
ODK connection protection during task execution:
- DB-managed ODK connections now use a shared guard backed by
mas_odk_connections - each outbound ODK request reserves a paced per-connection request slot
- retryable connectivity/auth failures increment a shared failure counter
- repeated retryable failures activate a shared cooldown, after which later requests fail fast until the cooldown expires
- successful requests clear cooldown and reset the shared failure count
Manual dispatch:
from app.tasks.sync_tasks import run_odk_sync
run_odk_sync.delay(triggered_by="manual", user_id=str(user_id))
app.tasks.sync_tasks.run_single_form_sync
Defined in app/tasks/sync_tasks.py.
Purpose: Force-resync a single form, bypassing the delta check entirely.
Signature:
run_single_form_sync(form_id: str, triggered_by: str = "manual")
Behavior:
- Looks up the
va_formsrow forform_id - Revalidates request-time access in the worker for non-admin user-triggered runs
- Fetches all submissions via OData JSON (no
sincefilter — downloads everything) - Upserts submissions, then enqueues the canonical repair task for the changed submission set
- Updates
mapping.last_synced_at
When to use: A form failed during a full sync run (status partial), or attachments are suspected to be out of sync. Triggered from the per-form sync button in the admin coverage table via POST /admin/api/sync/form/<form_id>.
Operational note:
- if the underlying ODK connection is in cooldown, the task fails fast rather than continuing to burst requests at Central
Manual dispatch:
from app.tasks.sync_tasks import run_single_form_sync
run_single_form_sync.delay(form_id="UNSW01KA0101", triggered_by="manual")
app.tasks.sync_tasks.run_single_form_backfill
Defined in app/tasks/sync_tasks.py.
Purpose: Backfill a single form by repairing local gaps without doing a full Force-resync.
Limits: soft_time_limit=1800s, time_limit=3600s
Signature:
run_single_form_backfill(form_id: str, triggered_by: str = "backfill")
Behavior:
- Looks up the
va_formsrow forform_id - Revalidates request-time access in the worker for non-admin user-triggered runs
- Fetches ODK instance IDs for the form and thin-fetches only missing local submissions
- Queues each fetched missing-data batch into the canonical repair task immediately
- Queues any already-local repair candidates through the same canonical repair task
- Hands run completion to a finalizer task once all queued repair batches finish
- Leaves already complete rows alone
When to use: The admin sync dashboard shows local completeness gaps for a
form and the operator wants targeted repair rather than a full Force-resync.
Triggered from POST /admin/api/sync/backfill/form/<form_id>.
app.tasks.sync_tasks.run_single_submission_sync
Defined in app/tasks/sync_tasks.py.
Purpose: Refresh one existing local submission from ODK, sync its attachments, and rerun SmartVA for that submission through the canonical repair engine.
Signature:
run_single_submission_sync(va_sid: str, triggered_by: str = "manual")
Behavior:
- Looks up the local
va_submissionsrow and resolves its ODK instance ID - Revalidates request-time access in the worker for non-admin user-triggered runs
- Fetches the latest ODK payload for that instance
- Marks
missing_in_odkif the submission is no longer present in active ODK - Upserts the local submission when found
- Enqueues the canonical repair task for that submission when local gaps remain
- Rebuilds the form CSV
app.tasks.sync_tasks.run_canonical_repair_batches_task
Defined in app/tasks/sync_tasks.py.
Purpose: Run the canonical per-submission current-payload repair engine across a bounded set of submission IDs for one form.
Signature:
run_canonical_repair_batches_task(
run_id: str,
form_id: str,
candidate_sids: list[str],
trigger_source: str,
force_attachment_redownload: bool = False,
)
Behavior:
- Chunks
candidate_sidsinto bounded batches of 5 - Runs
repair_submission_current_payload(...)for each submission - Aggregates attachment/audit download counts, SmartVA generation, and errors into
va_sync_runs - Finalizes the run status when the last queued repair batch for that sync run finishes
When to use: This is the async wrapper used by global sync, per-form force-resync, project/site sync, and single-submission refresh whenever they need current-payload-aware repair follow-through after ODK upsert.
When to use: A data manager wants to refresh one submission without waiting for a full-form or full-system sync.
Manual dispatch:
from app.tasks.sync_tasks import run_single_submission_sync
run_single_submission_sync.delay(
va_sid="uuid:example-unsw01ka0101",
triggered_by="manual",
)
app.tasks.sync_tasks.run_single_form_sync
Purpose: Download and upsert one mapped form, sync attachments, and rebuild SmartVA for that local compatibility form.
Signature:
run_single_form_sync(form_id: str, triggered_by: str = "manual")
Behavior:
- Looks up the local
va_formsrow - Revalidates request-time data-manager access in the worker for non-admin user-triggered runs
- Fetches all submissions for that mapped ODK form
- Upserts local submissions, then enqueues the canonical repair task for the changed submission set
- Updates
map_project_site_odk.last_synced_at - Regenerates SmartVA only through the canonical repair follow-through when needed
When to use: A form needs a targeted resync without running a full-system sync.
Admin dashboard coverage behavior:
- if a coverage row already has a local
va_formsrow, the per-form sync action dispatches this task directly - if a coverage row has an active project/site ODK mapping but
0local forms, the dashboard first materializes the compatibilityva_formsrow frommap_project_site_odk, then dispatches this same task
app.tasks.sync_tasks.refresh_submission_analytics_mv_task
Purpose: Refresh the submission analytics materialized view on an hourly cadence.
Behavior:
- Creates a
va_sync_runsrow withtriggered_by="analytics_mv" - Refreshes
va_submission_analytics_mvusingREFRESH MATERIALIZED VIEW CONCURRENTLY - Records the refreshed row count in
records_updated - Marks the run
successorerror
Default schedule: every 1 hour via Celery Beat
Beat Schedule Tables
The sqlalchemy_celery_beat scheduler creates these tables in the public schema:
| Table | Purpose |
|---|---|
celery_periodictask |
One row per scheduled task |
celery_intervalschedule |
Interval definitions (every N hours/minutes/etc.) |
celery_crontabschedule |
Cron-style definitions |
celery_periodictaskchanged |
Signals Beat to reload after a schedule change |
The discriminator column on celery_periodictask identifies the schedule type (intervalschedule, crontabschedule, etc.).
Schedule changes made via the admin sync dashboard update celery_periodictask.schedule_id and insert a row into celery_periodictaskchanged to trigger an immediate Beat reload.
Defining New Tasks
Use @shared_task (requires celery_app.set_default() to have been called, which celery_init_app does):
from celery import shared_task
from app import db
@shared_task(name="app.tasks.my_module.my_task", bind=True)
def my_task(self, some_arg):
# db.session and Flask config available here
...
Register the task with the worker by importing the module in make_celery.py:
import app.tasks.my_module # noqa: F401
Troubleshooting
- Task not found by worker: Ensure the task module is imported in
make_celery.py. - Beat not running scheduled tasks: Check
celery_periodictask.enabled = trueandcelery_periodictaskchangedhas a recent row. Restartminerva_celery_beatif needed. - Beat DB table errors on cold start: Beat waits 10 s at startup to allow
flask db upgradeto complete first. If tables are still missing, runflask db upgrademanually and restart Beat. - Orphaned
runningsync rows: Cleaned automatically on next worker startup and before each new sync run bycleanup_stale_runs()(45-minute threshold). - Repeated ODK connectivity failures: Check
mas_odk_connectionscooldown/failure state through the ODK Connections panel or the Sync Dashboard status alerts before retrying live ODK actions.