Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Release Notes
=============

Version 0.139.3
---------------

- Make featured homepage courses cache robust against task failures (#3353)

Version 0.139.2 (Released March 04, 2026)
---------------

Expand Down
15 changes: 7 additions & 8 deletions cms/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
from wagtail.rich_text import RichText

from cms import models as cms_models
from cms.constants import CERTIFICATE_INDEX_SLUG, INSTRUCTOR_INDEX_SLUG
from cms.constants import (
CERTIFICATE_INDEX_SLUG,
FEATURED_ITEMS_CACHE_KEY,
INSTRUCTOR_INDEX_SLUG,
)
from cms.exceptions import WagtailSpecificPageError
from cms.models import Page
from courses.models import Course, Program
Expand Down Expand Up @@ -477,9 +481,6 @@ def create_featured_items():
Used only by cron task or management command.
"""
redis_cache = caches["redis"]
cache_key = "CMS_homepage_featured_courses"

redis_cache.delete(cache_key)

now = now_in_utc()
end_of_day = now + timedelta(days=1)
Expand All @@ -491,7 +492,6 @@ def create_featured_items():
)

if not valid_course_ids:
redis_cache.set(cache_key, [])
return []

enrollable_courses_qs = Course.objects.select_related("page").filter(
Expand All @@ -504,7 +504,6 @@ def create_featured_items():
)

if not enrollable_courseruns.exists():
redis_cache.set(cache_key, [])
return []

self_paced_runs = []
Expand Down Expand Up @@ -537,7 +536,6 @@ def create_featured_items():
all_course_ids = self_paced_course_ids + future_course_ids + started_course_ids

if not all_course_ids:
redis_cache.set(cache_key, [])
return []

ordering = Case(
Expand All @@ -546,7 +544,8 @@ def create_featured_items():
)

# Store only course IDs to avoid pickling issues and ensure fresh data on retrieval
redis_cache.set(cache_key, all_course_ids)
# Use timeout=None so the cache never auto-expires; stale data is better than no data.
redis_cache.set(FEATURED_ITEMS_CACHE_KEY, all_course_ids, timeout=None)

return list(
Course.objects.filter(id__in=all_course_ids)
Expand Down
52 changes: 40 additions & 12 deletions cms/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
get_home_page,
get_wagtail_img_src,
)
from cms.constants import FEATURED_ITEMS_CACHE_KEY
from cms.exceptions import WagtailSpecificPageError
from cms.factories import CoursePageFactory, HomePageFactory, ProgramPageFactory
from cms.models import (
Expand Down Expand Up @@ -376,22 +377,38 @@ def test_create_featured_items():

@pytest.mark.django_db
def test_create_featured_items_no_courses():
"""Test create_featured_items with no courses at all"""
"""Test create_featured_items with no courses leaves stale cache intact"""
redis_cache = caches["redis"]
stale_value = [999]
redis_cache.set("CMS_homepage_featured_courses", stale_value)

result = create_featured_items()
cache_value = redis_cache.get("CMS_homepage_featured_courses")

# Returns empty list but does NOT wipe stale cache data
assert result == []
assert cache_value == stale_value


@pytest.mark.django_db
def test_create_featured_items_no_courses_no_prior_cache():
"""Test create_featured_items with no courses and no prior cache entry"""
redis_cache = caches["redis"]
redis_cache.delete("CMS_homepage_featured_courses")

result = create_featured_items()
cache_value = redis_cache.get("CMS_homepage_featured_courses")

assert result == []
assert cache_value == []
assert cache_value is None


@pytest.mark.django_db
def test_create_featured_items_no_enrollable_courses():
"""Test create_featured_items with courses but no enrollable runs"""
"""Test create_featured_items with courses but no enrollable runs leaves stale cache intact"""
redis_cache = caches["redis"]
redis_cache.delete("CMS_homepage_featured_courses")
stale_value = [888]
redis_cache.set("CMS_homepage_featured_courses", stale_value)

# Create course with no enrollable runs
course = CourseFactory.create(page=None, live=True)
Expand All @@ -401,8 +418,9 @@ def test_create_featured_items_no_enrollable_courses():
result = create_featured_items()
cache_value = redis_cache.get("CMS_homepage_featured_courses")

# Returns empty list but does NOT wipe stale cache data
assert result == []
assert cache_value == []
assert cache_value == stale_value


@pytest.mark.django_db
Expand Down Expand Up @@ -557,10 +575,10 @@ def test_create_featured_items_mixed_course_types():


@pytest.mark.django_db
def test_create_featured_items_cache_expiration():
"""Test that cache is set with correct expiration time"""
def test_create_featured_items_cache_no_expiry():
"""Test that cache is set with timeout=None so it never auto-expires"""
redis_cache = caches["redis"]
redis_cache.delete("CMS_homepage_featured_courses")
redis_cache.delete(FEATURED_ITEMS_CACHE_KEY)

# Create a simple course to have something to cache
course = CourseFactory.create(page=None, live=True)
Expand All @@ -576,10 +594,20 @@ def test_create_featured_items_cache_expiration():

create_featured_items()

# Verify cache is set
cache_value = redis_cache.get("CMS_homepage_featured_courses")
# Verify cache is still set and correct
cache_value = redis_cache.get(FEATURED_ITEMS_CACHE_KEY)
assert cache_value is not None
assert len(cache_value) == 1

# Note: TTL testing would require mocking or actual time measurement
# which might be flaky, so we just verify the cache is set
redis_client = redis_cache.client.get_client()
# redis returns -1 for a key being present with no expiry
# see https://redis.io/docs/latest/commands/ttl/
# note that the actual key in redis is prefixed with ":1:"
# we use the raw client, not the one provided by redis_django,
# because the latter rewrites the return values
ttl = redis_client.ttl(f":1:{FEATURED_ITEMS_CACHE_KEY}")
assert ttl == -1, (
f"Key `{FEATURED_ITEMS_CACHE_KEY}` is missing"
if ttl == -2
else f"Unexpected ttl value `{ttl}` for `{FEATURED_ITEMS_CACHE_KEY}`"
)
2 changes: 2 additions & 0 deletions cms/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@
INSTRUCTOR_INDEX_SLUG = "instructors"

ONE_MINUTE = 60

FEATURED_ITEMS_CACHE_KEY = "CMS_homepage_featured_courses"
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from main.sentry import init_sentry
from openapi.settings_spectacular import open_spectacular_settings

VERSION = "0.139.2"
VERSION = "0.139.3"

log = logging.getLogger()

Expand Down