| name | celery-async |
| description | Create, debug, and test Celery 5 async tasks with Redis. Use when adding background processing, scheduled jobs, or long-running AI operations. |
Celery Async Tasks
Patterns for Celery 5 + Redis with Django. All long-running operations (AI generation, git sync, notifications, pipeline execution) run as Celery tasks.
When to Use Celery
| Use Celery | Don't use Celery |
|---|
| AI content generation (> 1s) | Simple DB reads |
| Git repository sync | Short computations |
| Email/notification sending | Auth checks |
| Pipeline execution (autodev) | Data validation |
| PDF generation | CRUD operations |
| Anything user should not wait for | |
Rule: If a request takes > 2 seconds, move it to Celery.
Standard Task Pattern
import logging
from celery import shared_task
from django.utils import timezone
logger = logging.getLogger(__name__)
@shared_task(
bind=True,
max_retries=3,
default_retry_delay=60,
time_limit=300,
soft_time_limit=270,
name='<app>.tasks.<task_name>',
)
def my_task(self, obj_id: str) -> None:
"""<Description of what this task does>.
Args:
obj_id: UUID string of the target object
"""
logger.info(f"[{self.name}] START obj_id={obj_id} attempt={self.request.retries + 1}")
try:
from .<app>.models import MyModel
obj = MyModel.objects.get(id=obj_id)
result = do_the_work(obj)
obj.status = 'completed'
obj.completed_at = timezone.now()
obj.result = result
obj.save(update_fields=['status', 'completed_at', 'result'])
logger.info(f"[{self.name}] DONE obj_id={obj_id}")
except MyModel.DoesNotExist:
logger.warning(f"[{self.name}] SKIP obj_id={obj_id} — not found")
except SoftTimeLimitExceeded:
obj.status = 'failed'
obj.error = 'Timeout exceeded'
obj.save(update_fields=['status', 'error'])
logger.error(f"[{self.name}] TIMEOUT obj_id={obj_id}")
raise
except Exception as exc:
logger.error(f"[{self.name}] ERROR obj_id={obj_id}: {exc}", exc_info=True)
try:
obj.status = 'failed'
obj.error = str(exc)[:500]
obj.save(update_fields=['status', 'error'])
except Exception:
pass
raise self.retry(
exc=exc,
countdown=60 * (2 ** self.request.retries),
)
Dispatching Tasks Safely
Always use transaction.on_commit to avoid race conditions:
from django.db import transaction
def create_and_process(user, data):
obj = MyModel.objects.create(user=user, status='pending', **data)
transaction.on_commit(lambda: my_task.delay(str(obj.id)))
return obj
Never dispatch from a signal directly (same race condition):
@receiver(post_save, sender=MyModel)
def on_create(sender, instance, created, **kwargs):
if created:
my_task.delay(str(instance.id))
@receiver(post_save, sender=MyModel)
def on_create(sender, instance, created, **kwargs):
if created:
transaction.on_commit(lambda: my_task.delay(str(instance.id)))
Scheduled Tasks (Celery Beat)
from celery.schedules import crontab
app.conf.beat_schedule = {
'poll-issue-trackers': {
'task': 'autodev.tasks.poll_issue_trackers',
'schedule': crontab(minute='*/5'),
},
'sync-git-activity': {
'task': 'gitdata.tasks.sync_all_providers',
'schedule': crontab(hour='*/1'),
},
'generate-weekly-highlights': {
'task': 'articles.tasks.generate_weekly_highlights',
'schedule': crontab(day_of_week='monday', hour=9, minute=0),
},
}
Task Chaining and Groups
from celery import chain, group, chord
pipeline = chain(
analyze_demand.s(demand_id),
generate_plan.s(),
execute_implementation.s(),
create_pr.s(),
)
pipeline.delay()
sync_jobs = group(
sync_github.s(provider_id),
sync_gitlab.s(provider_id),
)
sync_jobs.delay()
processing = chord(
group(process_repo.s(repo_id) for repo_id in repo_ids),
generate_summary.s(),
)
processing.delay()
Testing Celery Tasks
import pytest
from unittest.mock import patch, MagicMock
from <app>.tasks import my_task
from tests.factories import MyModelFactory
@pytest.mark.django_db
def test_task_processes_successfully(db):
"""Task changes status to completed on success."""
obj = MyModelFactory(status='pending')
my_task.apply(args=[str(obj.id)])
obj.refresh_from_db()
assert obj.status == 'completed'
@pytest.mark.django_db
def test_task_retries_on_transient_error(db):
"""Task retries on transient errors (network, timeout)."""
obj = MyModelFactory(status='pending')
with patch('myapp.services.external_api.call', side_effect=ConnectionError('timeout')):
with pytest.raises(my_task.MaxRetriesExceededError):
my_task.apply(args=[str(obj.id)], retries=3)
obj.refresh_from_db()
assert obj.status == 'failed'
assert 'timeout' in obj.error.lower()
@pytest.mark.django_db
def test_task_skips_missing_object(db):
"""Task exits gracefully when object not found."""
non_existent_id = '00000000-0000-0000-0000-000000000000'
my_task.apply(args=[non_existent_id])
Settings for testing (conftest.py):
@pytest.fixture(autouse=True)
def celery_eager(settings):
"""Execute Celery tasks synchronously in all tests."""
settings.CELERY_TASK_ALWAYS_EAGER = True
settings.CELERY_TASK_EAGER_PROPAGATES = True
Debugging
docker compose logs -f celery
celery -A config inspect active
celery -A config inspect registered | grep <app>
celery -A config purge -Q <queue_name>
docker compose exec redis redis-cli llen celery
celery -A config flower --port=5555
Common Pitfalls
| Problem | Cause | Fix |
|---|
| Object not found in task | Task ran before DB commit | Use transaction.on_commit() |
| Duplicate processing | Task not idempotent | Add if obj.status != 'pending': return guard |
| Task runs forever | No time_limit | Add time_limit + soft_time_limit |
| Memory leak in worker | No worker recycling | Set CELERYD_MAX_TASKS_PER_CHILD=100 |
| N+1 in task | No prefetch in queryset | Add select_related('user', 'config') |