# Backend Async Hardening Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the current backend async work correct first, then establish safe patterns for expanding async views without changing API response shapes. **Architecture:** Keep the existing custom `APIView`/DRF serializer stack. Add async-safe tests and helpers around `AsyncAPIView`, explicitly preload serializer relations in converted endpoints, and use `sync_to_async(..., thread_sensitive=True)` for synchronous serializer/cache/helper code that remains inside async views. **Tech Stack:** Django 6.0.4, custom class-based API views, Django async ORM, DRF serializers, PostgreSQL, Redis cache, Channels ASGI. --- ## Async Rules For This Repository 1. Async conversion is valid only when the endpoint preserves URL, method, status code, JSON envelope, and permission behavior. 2. Every async view that serializes model instances must either preload all serializer relations with `select_related()` / `prefetch_related()` or run serializer `.data` through a sync boundary. 3. `asyncio.gather()` is only for independent reads. Do not use it around writes that depend on ordering or transaction semantics. 4. Keep file upload/download, SMTP, test-case pruning, judge heartbeat mutation paths, and contest permission-heavy flows sync until the async decorator and middleware work is complete. 5. Current sync `MiddlewareMixin` middleware means ASGI requests still cross sync boundaries. The first async milestone is correctness and latency cleanup, not a full event-loop purity claim. --- ### Task 1: Add Regression Coverage For Converted Async Detail Views **Files:** - Create: `utils/test_async_view_regressions.py` - [ ] **Step 1: Write failing regression tests** Create `utils/test_async_view_regressions.py`: ```python from datetime import timedelta from asgiref.sync import sync_to_async from django.contrib.auth import get_user_model from django.test import AsyncClient, TestCase from django.utils import timezone from account.models import UserProfile from announcement.models import Announcement from contest.models import Contest from flowchart.models import FlowchartSubmission from problem.models import Problem, ProblemRuleType from utils.constants import ContestRuleType, Difficulty User = get_user_model() def make_user(username="async_user"): user = User.objects.create(username=username, email=f"{username}@example.com") user.set_password("pass1234") user.save() UserProfile.objects.create(user=user) return user def make_problem(user): return Problem.objects.create( _id="ASYNC001", title="Async Problem", description="desc", input_description="input", output_description="output", samples=[], test_case_id="async-test-case", test_case_score=[], hint="", languages=["Python3"], template={}, created_by=user, time_limit=1000, memory_limit=128, rule_type=ProblemRuleType.ACM, difficulty=Difficulty.LOW, share_submission=False, allow_flowchart=True, show_flowchart=True, ) class AsyncConvertedViewRegressionTests(TestCase): @classmethod def setUpTestData(cls): cls.user = make_user() cls.announcement = Announcement.objects.create( title="Async Announcement", content="content", tag="notice", visible=True, top=False, created_by=cls.user, ) cls.contest = Contest.objects.create( title="Async Contest", description="contest desc", tag="weekly", real_time_rank=True, password=None, rule_type=ContestRuleType.ACM, start_time=timezone.now() - timedelta(hours=1), end_time=timezone.now() + timedelta(hours=1), created_by=cls.user, visible=True, allowed_ip_ranges=[], ) cls.problem = make_problem(cls.user) cls.flowchart_submission = FlowchartSubmission.objects.create( user=cls.user, problem=cls.problem, mermaid_code="graph TD\nA-->B", flowchart_data={}, ) async def test_announcement_detail_serializes_created_by(self): response = await AsyncClient().get(f"/api/announcement?id={self.announcement.id}") self.assertEqual(response.status_code, 200) body = response.json() self.assertIsNone(body["error"]) self.assertEqual(body["data"]["created_by"]["username"], self.user.username) async def test_contest_detail_serializes_created_by(self): response = await AsyncClient().get(f"/api/contest?id={self.contest.id}") self.assertEqual(response.status_code, 200) body = response.json() self.assertIsNone(body["error"]) self.assertEqual(body["data"]["created_by"]["username"], self.user.username) async def test_flowchart_detail_serializes_user_and_problem(self): client = AsyncClient() await sync_to_async(client.force_login)(self.user) response = await client.get(f"/api/flowchart/submission?id={self.flowchart_submission.id}") self.assertEqual(response.status_code, 200) body = response.json() self.assertIsNone(body["error"]) self.assertEqual(body["data"]["username"], self.user.username) self.assertEqual(body["data"]["problem"], self.problem.id) ``` - [ ] **Step 2: Run the regression tests** Run: ```bash rtk uv run python manage.py test utils.test_async_view_regressions -v 2 ``` Expected before Task 2: at least one test returns `server-error` or raises async-unsafe lazy relation access. - [ ] **Step 3: Commit the failing tests** Run: ```bash rtk git add utils/test_async_view_regressions.py rtk git commit -m "test: cover async view serialization regressions" ``` --- ### Task 2: Preload Relations For Already Converted Async Detail Views **Files:** - Modify: `announcement/views/oj.py` - Modify: `contest/views/oj.py` - Modify: `flowchart/views/oj.py` - [ ] **Step 1: Fix announcement detail relation loading** In `announcement/views/oj.py`, replace the detail query with: ```python announcement = await ( Announcement.objects.select_related("created_by") .filter(id=id, visible=True) .afirst() ) if announcement is None: raise Announcement.DoesNotExist ``` - [ ] **Step 2: Fix contest detail relation loading** In `contest/views/oj.py`, replace the detail query with: ```python contest = await ( Contest.objects.select_related("created_by") .filter(id=id, visible=True) .afirst() ) if contest is None: raise Contest.DoesNotExist ``` - [ ] **Step 3: Fix flowchart submission detail relation loading** In `flowchart/views/oj.py`, update `FlowchartSubmissionAPI.get()`: ```python submission = await ( FlowchartSubmission.objects.select_related("user", "problem") .filter(id=submission_id) .afirst() ) if submission is None: raise FlowchartSubmission.DoesNotExist ``` - [ ] **Step 4: Fix flowchart retry permission relation loading** In `flowchart/views/oj.py`, update `FlowchartSubmissionRetryAPI.post()`: ```python submission = await ( FlowchartSubmission.objects.select_related("problem") .filter(id=submission_id) .afirst() ) if submission is None: raise FlowchartSubmission.DoesNotExist ``` - [ ] **Step 5: Fix flowchart completed-detail relation loading** In `flowchart/views/oj.py`, update `FlowchartSubmissionDetailAPI.get()` before serialization: ```python submissions = ( FlowchartSubmission.objects.select_related("user", "problem") .filter( user=request.user, problem=problem, status=FlowchartSubmissionStatus.COMPLETED, ) .order_by("create_time") ) ``` - [ ] **Step 6: Run the regression tests** Run: ```bash rtk uv run python manage.py test utils.test_async_view_regressions -v 2 ``` Expected: all tests pass. - [ ] **Step 7: Run Django system checks** Run: ```bash rtk uv run python manage.py check ``` Expected: `System check identified no issues`. - [ ] **Step 8: Commit relation fixes** Run: ```bash rtk git add announcement/views/oj.py contest/views/oj.py flowchart/views/oj.py rtk git commit -m "fix: preload relations in async detail views" ``` --- ### Task 3: Add Async Serialization Helpers To `AsyncAPIView` **Files:** - Modify: `utils/api/api.py` - Modify: `utils/test_async_api.py` - [ ] **Step 1: Write tests for async serialization helper** Create `utils/test_async_api.py`: ```python import json from django.test import AsyncRequestFactory, SimpleTestCase from utils.api import AsyncAPIView, serializers, validate_serializer class PayloadSerializer(serializers.Serializer): name = serializers.CharField() class EchoSerializer(serializers.Serializer): name = serializers.CharField() class AsyncValidatedEchoView(AsyncAPIView): @validate_serializer(PayloadSerializer) async def post(self, request): return self.success({"name": request.data["name"]}) class AsyncSerializationHelperTests(SimpleTestCase): async def test_validate_serializer_supports_async_view_methods(self): request = AsyncRequestFactory().post( "/api/echo", data=json.dumps({"name": "alice"}), content_type="application/json", ) response = await AsyncValidatedEchoView.as_view()(request) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["error"], None) self.assertEqual(response.data["data"], {"name": "alice"}) async def test_async_serialize_data_returns_serializer_data(self): view = AsyncAPIView() data = await view.async_serialize_data( EchoSerializer, [{"name": "alice"}, {"name": "bob"}], many=True, ) self.assertEqual(data, [{"name": "alice"}, {"name": "bob"}]) ``` - [ ] **Step 2: Run helper tests and confirm failure** Run: ```bash rtk uv run python manage.py test utils.test_async_api -v 2 ``` Expected before implementation: `AttributeError: 'AsyncAPIView' object has no attribute 'async_serialize_data'`. - [ ] **Step 3: Add `sync_to_async` import** In `utils/api/api.py`, add: ```python from asgiref.sync import sync_to_async ``` - [ ] **Step 4: Add serializer helper methods** In `AsyncAPIView`, before `async_paginate_data()`, add: ```python def serialize_data(self, object_serializer, data, **kwargs): return object_serializer(data, **kwargs).data async def async_serialize_data(self, object_serializer, data, **kwargs): return await sync_to_async( self.serialize_data, thread_sensitive=True, )(object_serializer, data, **kwargs) ``` - [ ] **Step 5: Use helper inside async pagination** In `AsyncAPIView.async_paginate_data()`, replace: ```python if object_serializer: results = object_serializer(results, many=True, context={"request": request}).data ``` with: ```python if object_serializer: results = await self.async_serialize_data( object_serializer, results, many=True, context={"request": request}, ) ``` - [ ] **Step 6: Run helper and regression tests** Run: ```bash rtk uv run python manage.py test utils.test_async_api utils.test_async_view_regressions -v 2 ``` Expected: all tests pass. - [ ] **Step 7: Commit async serializer helper** Run: ```bash rtk git add utils/api/api.py utils/test_async_api.py rtk git commit -m "feat: add async serializer helper" ``` --- ### Task 4: Add Cache Helpers For Async Views **Files:** - Create: `utils/async_helpers.py` - Create: `utils/test_async_helpers.py` - Modify: `problem/views/oj.py` - Modify: `comment/views/oj.py` - [ ] **Step 1: Write cache helper tests** Create `utils/test_async_helpers.py`: ```python from django.test import SimpleTestCase, override_settings from utils.async_helpers import async_cache_delete, async_cache_get, async_cache_set @override_settings( CACHES={ "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "async-helper-tests", } } ) class AsyncCacheHelperTests(SimpleTestCase): async def test_async_cache_round_trip(self): await async_cache_set("async:key", {"value": 1}, 30) value = await async_cache_get("async:key") self.assertEqual(value, {"value": 1}) async def test_async_cache_delete(self): await async_cache_set("async:delete", "present", 30) await async_cache_delete("async:delete") value = await async_cache_get("async:delete") self.assertIsNone(value) ``` - [ ] **Step 2: Run tests and confirm import failure** Run: ```bash rtk uv run python manage.py test utils.test_async_helpers -v 2 ``` Expected before implementation: `ModuleNotFoundError: No module named 'utils.async_helpers'`. - [ ] **Step 3: Create async cache helpers** Create `utils/async_helpers.py`: ```python from asgiref.sync import sync_to_async from django.core.cache import cache async def async_cache_get(key, default=None): return await sync_to_async(cache.get, thread_sensitive=True)(key, default) async def async_cache_set(key, value, timeout=None): return await sync_to_async(cache.set, thread_sensitive=True)(key, value, timeout) async def async_cache_delete(key): return await sync_to_async(cache.delete, thread_sensitive=True)(key) ``` - [ ] **Step 4: Convert async problem cache calls** In `problem/views/oj.py`, add: ```python from utils.async_helpers import async_cache_get, async_cache_set ``` In `ProblemTagAPI.get()`, replace: ```python cached = cache.get(cache_key) ``` with: ```python cached = await async_cache_get(cache_key) ``` Replace: ```python cache.set(cache_key, data, 3600) ``` with: ```python await async_cache_set(cache_key, data, 3600) ``` - [ ] **Step 5: Convert async comment cache calls** In `comment/views/oj.py`, add: ```python from utils.async_helpers import async_cache_delete, async_cache_get, async_cache_set ``` In `CommentAPI.post()`, replace: ```python cache.delete(f"{CacheKey.comment_stats}:{problem.id}") ``` with: ```python await async_cache_delete(f"{CacheKey.comment_stats}:{problem.id}") ``` In `CommentStatisticsAPI.get()`, replace `cache.get()` and `cache.set()` with: ```python cached = await async_cache_get(cache_key) ``` and: ```python await async_cache_set(cache_key, data, 3600) ``` - [ ] **Step 6: Run helper and targeted async tests** Run: ```bash rtk uv run python manage.py test utils.test_async_helpers utils.test_async_api utils.test_async_view_regressions -v 2 ``` Expected: all tests pass. - [ ] **Step 7: Commit cache helper work** Run: ```bash rtk git add utils/async_helpers.py utils/test_async_helpers.py problem/views/oj.py comment/views/oj.py rtk git commit -m "feat: add async cache helpers" ``` --- ### Task 5: Make Permission Decorators Async-Aware Before More Conversions **Files:** - Modify: `account/decorators.py` - Create: `account/test_async_decorators.py` - [ ] **Step 1: Write tests for async `login_required`** Create `account/test_async_decorators.py`: ```python from django.contrib.auth.models import AnonymousUser from django.test import AsyncRequestFactory, SimpleTestCase from account.decorators import login_required from utils.api import AsyncAPIView class DisabledUser: is_authenticated = True is_disabled = True class ActiveUser: is_authenticated = True is_disabled = False class ProtectedAsyncView(AsyncAPIView): @login_required async def get(self, request): return self.success("ok") class AsyncPermissionDecoratorTests(SimpleTestCase): async def test_async_login_required_allows_active_user(self): request = AsyncRequestFactory().get("/api/protected") request.user = ActiveUser() response = await ProtectedAsyncView.as_view()(request) self.assertEqual(response.data["error"], None) self.assertEqual(response.data["data"], "ok") async def test_async_login_required_rejects_anonymous_user(self): request = AsyncRequestFactory().get("/api/protected") request.user = AnonymousUser() response = await ProtectedAsyncView.as_view()(request) self.assertEqual(response.data["error"], "permission-denied") self.assertEqual(response.data["data"], "Please login first") async def test_async_login_required_rejects_disabled_user(self): request = AsyncRequestFactory().get("/api/protected") request.user = DisabledUser() response = await ProtectedAsyncView.as_view()(request) self.assertEqual(response.data["error"], "permission-denied") self.assertEqual(response.data["data"], "Your account is disabled") ``` - [ ] **Step 2: Run decorator tests** Run: ```bash rtk uv run python manage.py test account.test_async_decorators -v 2 ``` Expected before implementation: this may pass because `AsyncAPIView.dispatch()` awaits returned coroutine. Keep the test anyway as a contract before refactoring. - [ ] **Step 3: Refactor `BasePermissionDecorator` with explicit async path** In `account/decorators.py`, add: ```python import inspect ``` Replace `BasePermissionDecorator.__get__()` with: ```python def __get__(self, obj, obj_type): if inspect.iscoroutinefunction(self.func): return functools.partial(self._async_call, obj) return functools.partial(self.__call__, obj) ``` Add this method to `BasePermissionDecorator`: ```python async def _async_call(self, *args, **kwargs): self.request = args[1] if self.check_permission(): if self.request.user.is_disabled: return self.error("Your account is disabled") return await self.func(*args, **kwargs) return self.error("Please login first") ``` - [ ] **Step 4: Run decorator and async view tests** Run: ```bash rtk uv run python manage.py test account.test_async_decorators utils.test_async_api utils.test_async_view_regressions -v 2 ``` Expected: all tests pass. - [ ] **Step 5: Commit decorator refactor** Run: ```bash rtk git add account/decorators.py account/test_async_decorators.py rtk git commit -m "refactor: make permission decorators async-aware" ``` --- ### Task 6: Convert One Endpoint Family At A Time **Files:** - Modify only the endpoint family being converted in each batch. - Add or update tests in the same app, using `AsyncClient` for converted URLs. - [ ] **Step 1: Choose the next low-risk batch** Use this order: ```text 1. Pure public GET list/detail endpoints already close to async: - announcement list/detail - contest list/detail - problem tag/list/detail 2. Authenticated read-only list endpoints: - message list - submission list - flowchart list/detail/current 3. Simple create/update endpoints with no contest permission decorator: - message create - comment create - flowchart retry/create ``` - [ ] **Step 2: For each endpoint, write one async smoke test** Use this template and replace the URL and assertions with the exact endpoint response: ```python from django.test import AsyncClient, TestCase class EndpointAsyncSmokeTests(TestCase): async def test_endpoint_returns_success_envelope(self): response = await AsyncClient().get("/api/endpoint?limit=10") self.assertEqual(response.status_code, 200) body = response.json() self.assertIn("error", body) self.assertIn("data", body) ``` - [ ] **Step 3: Convert ORM access only where async ORM exists** Use these replacements: ```python obj = await Model.objects.aget(id=id) count = await queryset.acount() first = await queryset.afirst() last = await queryset.alast() items = [item async for item in queryset[offset:offset + limit]] created = await Model.objects.acreate(field=value) await instance.asave(update_fields=["field"]) ``` - [ ] **Step 4: Keep sync-only helpers behind `sync_to_async`** Use this pattern: ```python from asgiref.sync import sync_to_async result = await sync_to_async(sync_helper, thread_sensitive=True)(arg1, arg2) ``` - [ ] **Step 5: Run targeted app tests and system check after each batch** Run: ```bash rtk uv run python manage.py test -v 2 rtk uv run python manage.py check ``` Expected: targeted tests pass and system check reports no issues. - [ ] **Step 6: Commit each endpoint family separately** Run: ```bash rtk git add rtk git commit -m "refactor: async endpoints" ``` --- ### Task 7: Audit Middleware Before Claiming Full Async Benefit **Files:** - Modify: `account/middleware.py` - Add tests only for behavior that changes. - [ ] **Step 1: Record current sync middleware boundaries** Before changing middleware, note these current sync classes: ```text account.middleware.APITokenAuthMiddleware account.middleware.AdminRoleRequiredMiddleware account.middleware.SessionRecordMiddleware ``` - [ ] **Step 2: Keep middleware sync during endpoint correctness work** Do not convert middleware in the same commit as endpoint conversions. Middleware affects every request and needs its own review. - [ ] **Step 3: If middleware conversion is pursued, convert one class per commit** Use Django new-style middleware with explicit sync and async handling. `__acall__` is a local helper; `__call__` dispatches to it when Django passes an async `get_response`: ```python from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async class ExampleMiddleware: sync_capable = True async_capable = True def __init__(self, get_response): self.get_response = get_response self.is_async = iscoroutinefunction(get_response) if self.is_async: markcoroutinefunction(self) def __call__(self, request): if self.is_async: return self.__acall__(request) response = self.process_request(request) if response is not None: return response return self.get_response(request) async def __acall__(self, request): response = await self.aprocess_request(request) if response is not None: return response return await self.get_response(request) def process_request(self, request): return None async def aprocess_request(self, request): return await sync_to_async(self.process_request, thread_sensitive=True)(request) ``` - [ ] **Step 4: Run full backend test command after middleware work** Run: ```bash rtk uv run python manage.py test -v 2 rtk uv run python manage.py check ``` Expected: all tests pass and system check reports no issues. --- ## Definition Of Done - `rtk uv run python manage.py check` passes. - Async regression tests cover converted detail serializers that depend on FK relations. - Converted async views do not call DRF serializer `.data` directly unless the data is primitive and relation-free. - Cache access from async views uses async helper wrappers. - New endpoint conversions are committed in endpoint-family-sized commits. - No file upload/download, SMTP, judge heartbeat, test-case prune, or contest permission-heavy endpoint is converted without a separate focused plan.