diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index acfe86ca8ed8..fbf920dbdffc 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -25,6 +25,7 @@ from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests from lms.djangoapps.course_home_api.toggles import COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory +from openedx.core.djangoapps.content.block_structure.api import update_course_in_cache from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.learning_sequences.api import replace_course_outline from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData, CourseVisibility @@ -907,3 +908,55 @@ def test_vertical_icon_determined_by_icon_class(self): response = self.client.get(reverse('course-home:course-navigation', args=[self.course.id])) vertical_data = response.data['blocks'][str(self.vertical.location)] assert vertical_data['icon'] == 'video' + + def test_navigation_does_not_cache_stale_data_after_publish(self): + """ + Regression test: after the block structure rebuild task completes, + the navigation sidebar should serve fresh data. + + This simulates a production scenario where: + 1. A unit is deleted and the course is auto-published + 2. The block structure rebuild Celery task is queued with a delay (30s by default) + 3. A learner hits the navigation endpoint during that 30s window + 4. The rebuild task completes (bumping block_structure_version) + 5. Another request arrives + + Without the fix, step 3 caches stale data under a key that step 5 + also hits (because course_version changed eagerly). With the fix, + the cache key uses block_structure_version which only changes when + the rebuild completes, so step 5 gets a cache miss and fresh data. + """ + self.add_blocks_to_course() + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + + # First request — populates both block structure and navigation cache + response = self.client.get(self.url) + assert response.status_code == 200 + sequential_data = response.data['blocks'][str(self.sequential.location)] + assert str(self.vertical.location) in sequential_data['children'] + + # Delete the vertical directly in the modulestore. Signals are disabled + # in ModuleStoreTestCase, so the block structure cache is now stale — + # mirroring the 30s window in production before the rebuild task runs. + self.store.delete_item(self.vertical.location, self.user.id) + update_outline_from_modulestore(self.course.id) + + # Request during the stale window — served from the pre-delete cache + # (block_structure_version hasn't changed yet, so same cache key). + response = self.client.get(self.url) + assert response.status_code == 200 + + # The vertical is still in the cache, even though it has been deleted + sequential_data = response.data['blocks'][str(self.sequential.location)] + assert str(self.vertical.location) in sequential_data['children'] + + # Now simulate the block structure rebuild task completing. + # This bumps block_structure_version → new cache key on next request. + update_course_in_cache(self.course.id) + + # Next request has a new cache key (version bumped) → cache miss → + # fresh data built from updated block structure. + response = self.client.get(self.url) + assert response.status_code == 200 + sequential_data = response.data['blocks'][str(self.sequential.location)] + assert str(self.vertical.location) not in sequential_data['children'] diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index b9168c6ca5fa..c50b2eed5e4e 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -51,6 +51,7 @@ from lms.djangoapps.courseware.views.views import get_cert_data from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.utils import OptimizelyClient +from openedx.core.djangoapps.content.block_structure.api import get_block_structure_version from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_404 from openedx.core.djangoapps.content.learning_sequences.api import get_user_course_outline from openedx.core.djangoapps.course_groups.cohorts import get_cohort @@ -434,7 +435,7 @@ class CourseNavigationBlocksView(RetrieveAPIView): serializer_class = CourseBlockSerializer COURSE_BLOCKS_CACHE_KEY_TEMPLATE = ( - 'course_sidebar_blocks_{course_key_string}_{course_version}_{user_id}_{user_cohort_id}' + 'course_sidebar_blocks_{course_key_string}_{block_structure_version}_{user_id}_{user_cohort_id}' '_{enrollment_mode}_{allow_public}_{allow_public_outline}_{is_masquerading}' ) COURSE_BLOCKS_CACHE_TIMEOUT = 60 * 60 # 1 hour @@ -469,7 +470,7 @@ def get(self, request, *args, **kwargs): cache_key = self.COURSE_BLOCKS_CACHE_KEY_TEMPLATE.format( course_key_string=course_key_string, - course_version=str(course.course_version), + block_structure_version=get_block_structure_version(course_key), user_id=request.user.id, enrollment_mode=getattr(enrollment, 'mode', ''), user_cohort_id=getattr(user_cohort, 'id', ''), diff --git a/openedx/core/djangoapps/content/block_structure/api.py b/openedx/core/djangoapps/content/block_structure/api.py index da1823cc8663..533a7fb32f22 100644 --- a/openedx/core/djangoapps/content/block_structure/api.py +++ b/openedx/core/djangoapps/content/block_structure/api.py @@ -3,12 +3,24 @@ """ +from uuid import uuid4 + from django.core.cache import cache from xmodule.modulestore.django import modulestore from .manager import BlockStructureManager +BLOCK_STRUCTURE_VERSION_KEY = 'block_structure_version:{}' + + +def get_block_structure_version(course_key): + """ + Returns the current block structure version for the given course. + This version changes each time the block structure cache is rebuilt. + """ + return cache.get(BLOCK_STRUCTURE_VERSION_KEY.format(course_key), '') + def get_course_in_cache(course_key): """ @@ -29,7 +41,8 @@ def update_course_in_cache(course_key): block_structure.updated_collected function that updates the block structure in the cache for the given course_key. """ - return get_block_structure_manager(course_key).update_collected_if_needed() + get_block_structure_manager(course_key).update_collected_if_needed() + cache.set(BLOCK_STRUCTURE_VERSION_KEY.format(course_key), str(uuid4()), timeout=None) def clear_course_from_cache(course_key):