app.celery_app is the sole Celery entrypoint, and every beat entry must name a registered task¶
Everything Celery runs starts from celery -A app.celery_app — the worker and Beat containers import nothing else — but the modules that give the app its content attached themselves elsewhere: tasks lived in app.worker, and conf.beat_schedule was filled by app.celery_beat_schedule, whose only importer was app.main. Beat never loads app.main, so it booted with an empty schedule and no periodic task fired at all, while a separately-rotted app/scheduled_tasks.py (truncated to zero bytes in a9a4270) left four beat entries naming tasks that no longer existed. Neither failure raised anything: an empty schedule is silent, and an unresolvable task name only surfaces as a NotRegistered message dying inside a worker. We made app/celery_app.py import both app.worker and app.celery_beat_schedule at module scope so the one entrypoint carries tasks and schedule together, deleted the four dead entries and the empty module rather than reimplementing them, and pinned the invariant with a test that walks conf.beat_schedule asserting every task resolves in celery_app.tasks. Celery's autodiscover_tasks() was rejected as a fix: it would have re-registered the tasks but hidden the same class of failure behind a convention, and conf.imports alone proved insufficient because it is not applied early enough at worker boot or under inspect.
Consequences¶
The two # noqa: E402, F401 imports at the foot of app/celery_app.py are load-bearing and must not be tidied away — removing either silently disables all scheduled work rather than failing. Any new task module that attaches to celery_app must be imported there too. Adding a beat entry for a task in a module nothing imports now fails the suite instead of failing in production.