From 4374146ebfe9662aac438f2e6782116a2fe55eea Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Thu, 18 Jun 2026 16:36:56 -0600 Subject: [PATCH 1/2] fix: ensure navigation sidebar serves fresh data after course publish After a course publish in Studio, the CourseNavigationBlocksView can cache stale block structure data for up to 1 hour. This happens because the block structure rebuild task runs with a 30-second delay, but the navigation view may be hit during that window, read the old block structure from its cache, and store the stale result under the new course_version key. The fix adds an update_collected_if_needed() call on cache miss, ensuring the block structure is fresh before we build and cache the navigation tree. This only runs on cache misses and adds negligible overhead for the common case (block structure already up-to-date). --- .../outline/tests/test_view.py | 34 +++++++++++++++++++ .../course_home_api/outline/views.py | 4 +++ 2 files changed, 38 insertions(+) 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..7578574f908b 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -907,3 +907,37 @@ 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_serves_fresh_data_after_publish(self): + """ + Regression test: the navigation sidebar should serve fresh data when + the modulestore has changed but the block structure cache is stale. + + 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 30s delay + 3. A learner hits the navigation endpoint during that 30s window + + Without the fix, stale block structure data gets cached for 1 hour. + """ + self.add_blocks_to_course() + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + + # First request — populates both block structure and navigation caches + 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) + + # Without the fix, this returns stale data with the deleted vertical. + # With the fix, update_collected_if_needed() detects staleness and rebuilds. + 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..e45a189b04f8 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_manager 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 @@ -483,6 +484,9 @@ def get(self, request, *args, **kwargs): course_blocks = cache.get(cache_key) if not course_blocks: + # Ensure the block structure cache is up-to-date before reading. + get_block_structure_manager(course_key).update_collected_if_needed() + if getattr(enrollment, 'is_active', False) or bool(staff_access): course_blocks = get_course_outline_block_tree(request, course_key_string, request.user) elif allow_public_outline or allow_public or user_is_masquerading: From 4490373d4f4ff9b88d367586add5bef3c4752101 Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Wed, 24 Jun 2026 11:03:05 -0600 Subject: [PATCH 2/2] fixup! fix: ensure navigation sidebar serves fresh data after course publish Replace synchronous update_collected_if_needed() with a version-based cache key approach. Instead of eagerly rebuilding the block structure on the request path (expensive, stampede risk), the navigation sidebar cache key now uses a block_structure_version that only bumps when the async rebuild task completes. This ensures stale data is never cached for 1 hour while avoiding any expensive work on the request path. --- .../outline/tests/test_view.py | 35 ++++++++++++++----- .../course_home_api/outline/views.py | 9 ++--- .../djangoapps/content/block_structure/api.py | 15 +++++++- 3 files changed, 44 insertions(+), 15 deletions(-) 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 7578574f908b..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 @@ -908,22 +909,27 @@ def test_vertical_icon_determined_by_icon_class(self): vertical_data = response.data['blocks'][str(self.vertical.location)] assert vertical_data['icon'] == 'video' - def test_navigation_serves_fresh_data_after_publish(self): + def test_navigation_does_not_cache_stale_data_after_publish(self): """ - Regression test: the navigation sidebar should serve fresh data when - the modulestore has changed but the block structure cache is stale. + 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 30s delay + 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, stale block structure data gets cached for 1 hour. + 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 caches + # 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)] @@ -935,8 +941,21 @@ def test_navigation_serves_fresh_data_after_publish(self): self.store.delete_item(self.vertical.location, self.user.id) update_outline_from_modulestore(self.course.id) - # Without the fix, this returns stale data with the deleted vertical. - # With the fix, update_collected_if_needed() detects staleness and rebuilds. + # 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)] diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index e45a189b04f8..c50b2eed5e4e 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -51,7 +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_manager +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 @@ -435,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 @@ -470,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', ''), @@ -484,9 +484,6 @@ def get(self, request, *args, **kwargs): course_blocks = cache.get(cache_key) if not course_blocks: - # Ensure the block structure cache is up-to-date before reading. - get_block_structure_manager(course_key).update_collected_if_needed() - if getattr(enrollment, 'is_active', False) or bool(staff_access): course_blocks = get_course_outline_block_tree(request, course_key_string, request.user) elif allow_public_outline or allow_public or user_is_masquerading: 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):