Django Tasks — Celery та Асинхронні Задачі¶
tasks.py— це транспортний шар для фонових операцій. Task не містить бізнес-логіки. Він лише ставить роботу у чергу і делегує виконання в Service.
Навігація¶
| Розділ | Що всередині |
|---|---|
| Навіщо Celery | Проблема блокуючих операцій |
| Архітектура | Broker, Worker, Backend |
| Анатомія Task | Базова структура, правила |
| Task = Transport Layer | Чому логіка не в task |
| on_commit | Гарантія цілісності з БД |
| Retry та помилки | autoretry_for, max_retries, countdown |
| Ідемпотентність | Задачі безпечні для повторного запуску |
| Типові патерни | Chains, Groups, Chords |
| Конфігурація | Налаштування Celery у Django |
| Антипатерни | Що не робити і чому |
| Шаблон | Copy-paste шаблон для нової task |
Навіщо Celery¶
Проблема блокуючих операцій¶
HTTP-запит має вкластись у ~200–500 мс. Деякі операції набагато довші:
| Операція | Час | Проблема |
|---|---|---|
| Відправка email | 1–3 с | Клієнт чекає |
| Обробка DEM-файлу | 30–120 с | Timeout у Nginx/клієнта |
| ICESat-2 запит до SlideRule | 10–60 с | Воркер заблоковано |
| ML inference | 5–30 с | Клієнт чекає |
| Генерація PDF-звіту | 3–10 с | Клієнт чекає |
Рішення: асинхронна черга¶
HTTP Request
│
▼
View → ставить задачу в чергу → одразу повертає 202 Accepted
│
▼ (незалежно, у фоні)
Celery Worker → виконує задачу → зберігає результат
Клієнт не чекає. Воркер не блокує HTTP-запити.
Архітектура Celery + Django¶
Django App
│
│ task.delay(args) ← ставить задачу у чергу
▼
Redis (Broker) ← черга повідомлень
│
│ отримує задачу
▼
Celery Worker ← окремий процес, виконує задачу
│
│ результат (optional)
▼
Redis / PostgreSQL (Backend) ← зберігає результат якщо потрібно
| Компонент | Роль |
|---|---|
| Broker | Черга повідомлень (зазвичай Redis або RabbitMQ) |
| Worker | Окремий Python-процес що виконує задачі |
| Backend | Сховище результатів (Redis, PostgreSQL) — опційно |
| Beat | Планувальник для periodic tasks (аналог cron) |
Анатомія Task¶
Базова структура¶
# apps/flood/tasks.py
from celery import shared_task
@shared_task
def notify_emergency_services_task(event_id: int) -> None:
from apps.flood.selectors import flood_event_get_by_id
from apps.flood.services import flood_event_notify
event = flood_event_get_by_id(event_id=event_id)
flood_event_notify(event=event)
Чому імпорти всередині функції¶
Імпорти на рівні модуля можуть спричинити циклічні залежності при старті Celery worker. Локальні імпорти всередині task функції — безпечний патерн.
# НЕБЕЗПЕЧНО — циклічний імпорт при старті worker
from apps.flood.services import flood_event_notify
@shared_task
def notify_task(event_id):
...
# БЕЗПЕЧНО — імпорт тільки при виконанні task
@shared_task
def notify_task(event_id):
from apps.flood.services import flood_event_notify
...
Передача аргументів¶
# ДОБРЕ — передавати прості типи: int, str, list, dict
@shared_task
def process_flood_task(event_id: int) -> None:
...
# Виклик
process_flood_task.delay(event_id=42)
# ПОГАНО — передавати Python-об'єкти (не серіалізуються надійно)
@shared_task
def process_flood_task(event: FloodEvent) -> None: # ❌
...
Celery серіалізує аргументи через JSON. Передавай ID, рядки, числа — не ORM-об'єкти.
Task = Transport Layer¶
Task — це черга доставки виклику, не місце для логіки.
Антипатерн: логіка в task¶
# ПОГАНО — вся логіка у task
@shared_task
def process_flood_task(event_id: int):
event = FloodEvent.objects.get(id=event_id)
event.status = 'processing'
event.save()
# ML pipeline
result = run_ml_model(event.geometry)
event.risk_level = result['risk']
event.status = 'done'
event.save()
# Email
send_mail(
subject='Flood processed',
message=f'Event {event_id} done',
from_email='system@example.com',
recipient_list=[event.created_by.email],
)
Проблеми: - Логіку не можна викликати з View або CLI без Celery - Не можна протестувати без запуску worker - При retry — весь pipeline запускається знову
Правильно: task делегує в service¶
# ДОБРЕ — task тільки транспортує виклик
@shared_task
def process_flood_task(event_id: int) -> None:
from apps.flood.selectors import flood_event_get_by_id
from apps.flood.services import flood_event_process
event = flood_event_get_by_id(event_id=event_id)
flood_event_process(event=event) # вся логіка у service
# services.py — логіка тут, незалежно від Celery
@transaction.atomic
def flood_event_process(*, event: FloodEvent) -> FloodEvent:
event.status = 'processing'
event.save(update_fields=['status'])
result = run_ml_model(event.geometry)
event.risk_level = result['risk']
event.status = 'done'
event.save(update_fields=['risk_level', 'status'])
transaction.on_commit(
lambda: send_flood_report_task.delay(event.id)
)
return event
Ту саму flood_event_process можна викликати з View, management command, тесту — без Celery.
on_commit — Безпечний запуск Task¶
Проблема без on_commit¶
@transaction.atomic
def flood_event_create(*, region_name: str, ...) -> FloodEvent:
event = FloodEvent(...)
event.save()
# ❌ НЕБЕЗПЕЧНО:
# Celery worker може запуститись ДО того як транзакція закрилась.
# Worker робить SELECT — запис ще не з'явився в БД.
# Task падає з DoesNotExist.
process_flood_task.delay(event.id)
return event
Рішення¶
@transaction.atomic
def flood_event_create(*, region_name: str, ...) -> FloodEvent:
event = FloodEvent(...)
event.save()
# ✅ БЕЗПЕЧНО:
# Task ставиться в чергу ТІЛЬКИ після успішного commit.
# Якщо транзакція відкотиться — task не запуститься взагалі.
transaction.on_commit(
lambda: process_flood_task.delay(event.id)
)
return event
Правило: будь-який
task.delay()всередині@transaction.atomic— завжди черезon_commit.
Retry та обробка помилок¶
autoretry_for — автоматичний retry при винятках¶
import httpx
from celery import shared_task
@shared_task(
autoretry_for=(httpx.HTTPError, ConnectionError), # при яких помилках повторювати
max_retries=3, # максимум спроб
countdown=60, # затримка між спробами (секунди)
retry_backoff=True, # експоненційне збільшення затримки
)
def fetch_sliderule_data_task(job_id: int) -> None:
from apps.satellite.services import icesat2_job_fetch
icesat2_job_fetch(job_id=job_id)
З retry_backoff=True затримки будуть: 60с → 120с → 240с.
Ручний retry¶
@shared_task(bind=True, max_retries=5)
def process_dem_task(self, dem_id: int) -> None:
from apps.dem.services import dem_process
try:
dem_process(dem_id=dem_id)
except TemporaryProcessingError as exc:
# Ручний retry з кастомною затримкою
raise self.retry(exc=exc, countdown=30 * (self.request.retries + 1))
bind=True дає доступ до self — поточного екземпляра task з метаданими.
Обробка після всіх спроб¶
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
@shared_task(
autoretry_for=(Exception,),
max_retries=3,
countdown=60,
)
def critical_processing_task(job_id: int) -> None:
from apps.flood.services import flood_job_process, flood_job_mark_failed
try:
flood_job_process(job_id=job_id)
except Exception as exc:
logger.error(f"Task failed for job_id={job_id}: {exc}")
flood_job_mark_failed(job_id=job_id)
raise # дає Celery знати що task впав → retry або FAILURE
Ідемпотентність¶
Task може запуститись кілька разів (мережева помилка, retry, дублікат повідомлення). Кожен task повинен бути ідемпотентним — повторний запуск з тими самими аргументами не повинен зламати систему.
Неідемпотентний (небезпечний)¶
@shared_task
def send_welcome_email_task(user_id: int) -> None:
user = User.objects.get(id=user_id)
send_mail(subject='Ласкаво просимо!', ...)
# При retry — email відправиться вдруге
Ідемпотентний (безпечний)¶
@shared_task
def send_welcome_email_task(user_id: int) -> None:
user = User.objects.get(id=user_id)
if user.welcome_email_sent: # перевірка перед відправкою
return
send_mail(subject='Ласкаво просимо!', ...)
User.objects.filter(id=user_id).update(welcome_email_sent=True)
Типові патерни¶
Простий delay — запустити і забути¶
# Запуск task у фоні
process_flood_task.delay(event_id=42)
# З keyword arguments
process_flood_task.apply_async(kwargs={'event_id': 42})
apply_async — розширені опції¶
from datetime import timedelta
from django.utils import timezone
# Затримати виконання на 10 хвилин
process_flood_task.apply_async(
kwargs={'event_id': 42},
countdown=600,
)
# Виконати в конкретний час
process_flood_task.apply_async(
kwargs={'event_id': 42},
eta=timezone.now() + timedelta(hours=1),
)
# Поставити у специфічну чергу
process_flood_task.apply_async(
kwargs={'event_id': 42},
queue='high_priority',
)
Chain — послідовне виконання¶
from celery import chain
# Task 2 запускається тільки після успішного завершення Task 1
pipeline = chain(
fetch_sliderule_data_task.s(job_id=42),
process_icesat2_points_task.s(job_id=42),
finalize_icesat2_job_task.s(job_id=42),
)
pipeline.delay()
Group — паралельне виконання¶
from celery import group
# Всі tasks запускаються паралельно
parallel_jobs = group(
process_dem_task.s(dem_id=1),
process_dem_task.s(dem_id=2),
process_dem_task.s(dem_id=3),
)
parallel_jobs.delay()
Periodic Tasks (Celery Beat)¶
# config/celery.py
from celery.schedules import crontab
app.conf.beat_schedule = {
'cleanup-old-jobs': {
'task': 'apps.flood.tasks.cleanup_old_jobs_task',
'schedule': crontab(hour=3, minute=0), # щодня о 3:00
},
'sync-satellite-data': {
'task': 'apps.satellite.tasks.sync_icesat2_data_task',
'schedule': crontab(minute='*/30'), # кожні 30 хвилин
},
}
Конфігурація¶
config/celery.py¶
# config/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base')
app = Celery('project')
app.config_from_object('django.conf:settings', namespace='CELERY')
# Автоматичне виявлення tasks.py у всіх INSTALLED_APPS
app.autodiscover_tasks()
config/__init__.py¶
settings.py¶
# settings.py
CELERY_BROKER_URL = 'redis://redis:6379/0'
CELERY_RESULT_BACKEND = 'redis://redis:6379/0'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'UTC'
# Важливо: завжди підтверджувати задачу ПІСЛЯ виконання
# (не після отримання — захист від втрати при падінні worker)
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
Запуск worker¶
# Базовий запуск
celery -A config worker --loglevel=info
# З кількома чергами і concurrency
celery -A config worker \
--loglevel=info \
--concurrency=4 \
--queues=default,high_priority
# Celery Beat (periodic tasks)
celery -A config beat --loglevel=info
Антипатерни¶
Бізнес-логіка в task¶
# ПОГАНО
@shared_task
def process_task(event_id):
event = FloodEvent.objects.get(id=event_id)
event.status = 'done' # логіка тут
event.save()
# ДОБРЕ
@shared_task
def process_task(event_id):
from apps.flood.services import flood_event_complete
event = flood_event_get_by_id(event_id=event_id)
flood_event_complete(event=event)
Передача ORM-об'єкта в task¶
# ПОГАНО — об'єкт серіалізується через pickle, нестабільно
process_task.delay(event=flood_event_object)
# ДОБРЕ — передавати тільки ID
process_task.delay(event_id=flood_event_object.id)
task.delay() без on_commit всередині транзакції¶
# ПОГАНО — race condition з БД
@transaction.atomic
def create_event():
event = FloodEvent.objects.create(...)
process_task.delay(event.id) # worker може не знайти запис
# ДОБРЕ
@transaction.atomic
def create_event():
event = FloodEvent.objects.create(...)
transaction.on_commit(lambda: process_task.delay(event.id))
Синхронний виклик task у View¶
# ПОГАНО — блокує HTTP-відповідь
result = process_task(event_id=42) # виконується синхронно
# ДОБРЕ — ставить у чергу і повертає одразу
process_task.delay(event_id=42)
return Response({'status': 'queued'}, status=202)
Таблиця: що task повинен і не повинен¶
| Дія | Повинен? |
|---|---|
| Отримувати прості аргументи (int, str) | ✅ |
| Виконувати SELECT для отримання об'єкта | ✅ (через selector) |
| Делегувати в service | ✅ |
| Логувати помилки | ✅ |
| Бути ідемпотентним | ✅ |
| Містити бізнес-логіку | ❌ |
| Отримувати ORM-об'єкти як аргументи | ❌ |
Відкривати @transaction.atomic |
❌ (це роль service) |
Повертати Response |
❌ |
| Знати про HTTP | ❌ |
Шаблон¶
# apps/<app>/tasks.py
from celery import shared_task
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
@shared_task(
autoretry_for=(Exception,),
max_retries=3,
countdown=60,
retry_backoff=True,
)
def <model>_<action>_task(<model>_id: int) -> None:
from apps.<app>.selectors import <model>_get_by_id
from apps.<app>.services import <model>_<action>
logger.info(f"Starting <action> for <model>_id={<model>_id}")
obj = <model>_get_by_id(<model>_id=<model>_id)
<model>_<action>(obj=obj)
logger.info(f"Completed <action> for <model>_id={<model>_id}")
# Виклик із service через on_commit
@transaction.atomic
def <model>_create(*, <field>: <type>) -> <Model>:
obj = <Model>(<field>=<field>)
obj.full_clean()
obj.save()
transaction.on_commit(
lambda: <model>_<action>_task.delay(<model>_id=obj.id)
)
return obj
Зв'язок з рештою документації¶
| Тема | Файл |
|---|---|
Services (on_commit, бізнес-логіка якій делегує task) |
DJANGO_SERVICES.md |
| Selectors (читання даних всередині task) | DJANGO_SELECTORS.md |
| Повна картина бізнес-шару | DJANGO_SERVICES_SELECTORS.md |
Структура проєкту (infrastructure/celery/) |
DJANGO_PROJECT_STRUCTURE.md |