From 4374146ebfe9662aac438f2e6782116a2fe55eea Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Thu, 18 Jun 2026 16:36:56 -0600 Subject: [PATCH] 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: