Celery in this repository¶
How background tasks are configured, discovered, and run in production versus pytest.
Application instance¶
- Shared Celery app object:
src/tasks/celery_app.py(celery, locks, broker/conf). Job modules import from here (from tasks.celery_app import celery) so they do not pull full task registration. - Full worker / beat / Flower:
celery -A tasks.celery:celery— registers every job module +listener.tasksand wires RedBeat (PERIODIC_TASKS). Use themodule:attrform; bare-A tasks.celeryis ambiguous with thetaskspackage. - Listener worker:
celery -A tasks.celery_listener:celery— registers onlytasks.jobs.match_listeners(no MLB/NHL/NFL refresh, archive/S3, or answer-eval imports). Compose pointscelery-listener-workerat this entry. - Task modules decorate callables with
@celery.task:
- Package
__init__files undertasks/tasks.jobsstay empty of job re-exports soimport tasksdoes not load Sportradar/S3 stacks.
Broker and result backend¶
- Broker: Redis db2 (
REDIS_URLwith path/2; seetasks/celery.py).visibility_timeoutis 12 hours so long-runningmatch_listenerstasks are not redelivered while still running (task_acks_late). - Ack behaviour (
src/tasks/celery.py): task_acks_late=True— the broker message is acknowledged after the task body finishes.task_acks_on_failure_or_timeout=True— a raised FAILURE (and soft/hard time limits) still acks the message. The broker does not redeliver that invocation.- Re-runs of
start_match_listenercome only from Beat / manual /forceenqueue (pending + attempt-cap keys), not from Celery retry. - If a worker is hard-killed before ack, Redis may redeliver after
visibility_timeout(12h). That path is distinct from Beat re-enqueue. - RedBeat: Redis db1.
- App cache / Celery locks / live play: Redis db0 (
celerylock:{id}viatask_lock; play open-questions cacheplay:open:sharedand pub/subplay:match:*— see Live play client and Environment configuration). - Result backend:
- Non-test:
db+{database_url}so results are stored in PostgreSQL (Celery’s database backend expects thedb+prefix). APP_ENV=test: Redis only. At import time the test database URL may not be set yet, so the app avoids binding the backend to Postgres during test collection (see the comment intasks/celery.py).result_expires: 7 days (celery.conf.result_expires). Expiredcelery_taskmetarows are removed by the built-incelery.backend_cleanuptask, scheduled daily inPERIODIC_TASKSascelery_backend_cleanup_daily.- Fire-and-forget jobs (answer eval, archive/sweep, Sportradar refreshes,
dispatch) use
@celery.task(ignore_result=True)so they do not write result rows.start_match_listenerstill stores results for Flower / ops status.
Worker process recycle¶
Compose workers pass Celery --max-tasks-per-child:
celery-listener-worker:--max-tasks-per-child=1so each finished match recycles the prefork child (clears per-game heap / FD residue). Uses-A tasks.celery_listener:celeryso idle children do not import archive/S3 or league refresh job modules (base RSS ~20–25 MiB lower per process than the full app).celery-worker:--max-tasks-per-child=200for short tasks (-A tasks.celery:celery).
Import-smoke coverage: src/lib/tests/test_base_memory_import_smoke.py
(API and listener entry must not load lib.s3 / botocore / unrelated jobs).
Worker log lines¶
Every worker log line carries the active job name and a task-id shortcode:
- Format:
job_name[task_id_shortcode], wherejob_nameis the bare task function name (not the dotted path). The shortcode is the first 8 characters of the task UUID; custom ids ending in a UUID (such asstart-match-listener.{internal_match_id}.{enqueue_uuid}) use that trailing UUID rather than the string prefix. The full task id stays in Flower and DB bookkeeping (MatchListenerRecord.celery_task_id,Match.listener_start_settings['celery_task_id']). - Idle worker / beat lines and non-worker CLI runs render both fields as
-. - Implemented in
src/tasks/task_logging.py:task_prerunstashes the job name and shortcode in context vars, andCeleryTaskLogFiltercopies them onto everyLogRecord. Context vars set intask_prerunremain visible through theasync_to_syncbody (same worker thread), unlike Celery's stockcurrent_taskfields, which areNoneinsideasync_to_sync. src/tasks/celery.pysetsworker_log_format/worker_task_log_format. Match-listener loggers keep a plainLISTENER_LOG_FORMAT(no job/task fields): under a worker, Celery'sworker_redirect_stdoutsalready wraps stdout with the worker format, so putting those fields in the listener format would double the prefix on console.
Sentry¶
General Celery workers initialize Sentry on worker_init when SENTRY_DSN_CELERY
is set; the listener-queue worker uses SENTRY_DSN_LISTENER. Full behaviour
(integrations, listener scope, start-failure fingerprinting): Sentry.
Task autodiscovery¶
celery.autodiscover_tasks() is configured for:
taskswithrelated_name="jobs"→tasks.jobsapi,srsim,listenerwith the defaultrelated_name="tasks"
Put Celery wrappers under src/tasks/jobs/. Async implementations stay in
lib (for example Sportradar refresh modules).
Running tasks in production¶
- Workers consume tasks from Redis.
- Beat (RedBeat) can schedule periodic work; see
src/tasks/README.mdfor RedBeat andtask_lockusage. Sportradar match rows: 10-minute window jobs (MLB daily / NHL daily / NFL weekly) plus once-dailyrefresh_active_season_matches(07:00 UTC) for every non-closed season on enabled MLB/NHL/NFL. Manualrefresh_*_matchesstays on-demand for a full year / first load. - Match event metrics (
MATCH_EVENT_METRICS_ENABLED, default off): live listener flush is best-effort. Catch-up isingest_match_event_metrics/sweep_pending_match_event_metrics(every 5 min) plus dailyprune_match_event_metrics(14-day retention). Staging yesterday backfill:
make enqueue TASK=sweep_pending_match_event_metrics \
KW='scheduled_from="2026-08-31T00:00:00Z" scheduled_to="2026-09-01T00:00:00Z"' \
ENV=staging
Skip is_test matches. Jobs call get_base_environment() only inside the
task body. Staging charts: Metabase (collection Match
timelines, dashboard Match timeline). Role metabase needs SELECT
on match_event_metrics and match_timeline_points (revision
b8e4c1a90d27; no-op locally where that role does not exist).
- Callers enqueue work with .delay(*args, **kwargs) or .apply_async(...).
Test environment (pytest)¶
src/tests/conftest.py registers pytest_configure, which sets:
celery.conf.task_always_eager = True— tasks run in the same process immediately instead of being sent to a worker.celery.conf.task_eager_propagates = True— exceptions inside the task body propagate to the test (failed assertions and errors fail the test).
So integration tests do not need a running Celery worker; .delay() still executes the full task body.
Database sessions and eager tasks¶
Eager tasks are real task invocations. Implementations that open a new database session (for example async with db_session() inside a task wrapped with async_to_sync) use the shared engine but a different connection than a test’s db fixture session. Uncommitted work that only lives on the test session (including savepoint-only “commits”) is not visible to that separate connection.
Production: commit the issuance (and any prerequisite rows) before enqueueing so workers see committed data. evaluate_question_answers_batch retries transient failures (exponential backoff + jitter, max 3) — safe because scoring only updates QuestionAnswer rows from already-committed issuance resolution state.
Pytest: enqueue_answer_evaluation_batches in src/listener/lib/stages/answer_evaluation.py checks celery.conf.task_always_eager and, when true, scores answers on the same AsyncSession the listener used (via _execute_question_logic) instead of calling .delay(). That is not because sync_run_async is wrong under eager mode — it is because eager .delay() still runs the task on a different DB connection than the test db fixture, so it would not see rows that only exist inside the fixture’s outer transaction / savepoints. Production workers use .delay() and their own session as usual.
Other tests that call scoring helpers directly should pass the test session into evaluate_question_answers_batch_impl(..., db=db) (see lib/tests/question_engine/conftest.py).
Async database code inside sync Celery tasks¶
Celery task bodies are synchronous. The usual pattern in this repo is:
- Implement
async def ..._impl(...)usingdb_session()fromsrc/lib/db/utils.py. - Wrap it with
sync_run_asyncfromsrc/tasks/async_bridge.pyand call that from@celery.task:
from tasks.async_bridge import sync_run_async
_run_impl = sync_run_async(my_async_impl)
@celery.task
def my_task(arg: str) -> None:
_run_impl(arg)
How sync_run_async behaves¶
The helper does not read task_always_eager. It only checks whether the current thread already has a running asyncio loop (asyncio.get_running_loop()).
| Where the task runs | Typical loop on the task thread? | What happens |
|---|---|---|
Production worker (task_always_eager false) |
No — worker threads are plain sync | async_to_sync runs in that thread. No extra thread pool. Same as writing async_to_sync(my_async_impl)(...) by hand. |
Pytest + eager Celery (task_always_eager true) inside an async test |
Yes — pytest-asyncio owns the loop | The wrapped coroutine runs in a short-lived worker thread so async_to_sync can create/use its own loop (avoids “AsyncToSync in the same thread as an async event loop”). |
So task_always_eager being false in production does not disable or change this helper — workers simply take the “no running loop” path every time.
:func:asgiref.sync.sync_to_async is for calling sync code from async code; it does not replace sync_run_async for Celery tasks.
Prefer the same-session eager path in calling code when task_always_eager is set and tests use a shared AsyncSession (see above).
Examples: src/listener/tasks.py, src/lib/sportradar/nhl/refresh_nhl_matches.py.
Related documentation¶
- Match listener startup — Beat → dispatch →
match_listenersqueue → settings merge. - Test system — shared pytest plugin, including the Celery row in the
pytest_configuretable. src/tasks/README.md— periodic tasks, locking, and operational notes.