fix: skip blocks with uninstalled XBlock types in item bank collect - #39132
blarghmatey wants to merge 2 commits into
Conversation
|
Thanks for the pull request, @blarghmatey! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
|
@openedx/wg-maintenance-openedx-platform-oncall this is ready for engineering review. Build is green. Short version: We hit this in production on a course holding a block of type Two things worth a reviewer's attention:
I flagged this for backport consideration in the description since an affected course is completely inaccessible and cannot be recovered from the operator side. |
ContentLibraryTransformer.collect() calls XBlock.load_class() on every block
during topological traversal, with nothing catching PluginMissingError:
filter_func=lambda block_key: issubclass(
XBlock.load_class(block_key.block_type), ItemBankMixin
),
The platform already has a defined answer for a block whose type has no
installed XBlock: the modulestore calls load_class with its configured
default_class, so the block loads as a HiddenBlock and Studio renders it as an
unknown component. This call site passes no default, so it raises instead, and
one such block aborts the block structure build for the entire course. Because
the build fails nothing is written to BlockStructureModel, the fallback path
raises BlockStructureNotFound, and the next request repeats the identical
failure, so the course never recovers on its own.
We hit this in production on a course holding a block of type 'p', a bare <p>
element promoted to block level by an OLX import. Every learner-facing
courseware API for that course returned 500 continuously: course outline,
courseware metadata, dates, progress, discussion topics, course blocks, and
recalculate_subsection_grade_v3. update_course_in_cache_v2 failed the same
way, so the structure could not be rebuilt either.
All four load_class call sites in the module now go through _load_block_class,
which returns None for an uninstalled type, and the collect() filter treats an
unloadable type as "not an ItemBankMixin". Two of the module's existing
`block_class is None` guards become reachable as a result; the other two sit
behind an earlier guard on the same block type and remain unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdSgbza4DbvMheSvFVz9pZ
ruff I001 and pylint C0305 from the collect() regression tests added in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BdSgbza4DbvMheSvFVz9pZ
6791937 to
b4c088e
Compare
Description
ContentLibraryTransformer.collect()callsXBlock.load_class()on every block during topological traversal, with nothing catchingPluginMissingError.The platform already has a defined answer for a block whose type has no installed XBlock. The modulestore calls
load_classwith its configureddefault_class(xmodule.hidden_block.HiddenBlockby default), so such a block loads as aHiddenBlockand Studio renders it as an unknown component. This call site passes no default, so it raises instead, and one such block aborts the block structure build for the entire course.Because the build fails, nothing is written to
BlockStructureModel, the fallback path raisesBlockStructureNotFound, and the next request repeats the identical failure. There is no cached structure to serve and no way to create one, so the course stays inaccessible until the content is edited.This adds
_load_block_class, which returnsNonewhen the XBlock is not installed, and routes the module's fourload_classcall sites through it. Thecollect()filter now treats an unloadable type as "not an ItemBankMixin" and logs a warning naming the block, rather than raising. Two of the module's existingblock_class is Noneguards become reachable as a result; the other two sit behind an earlier guard on the same block type and remain unreachable.Impacted user roles: Learner (the course becomes completely inaccessible), Course Author (nothing in Studio explains why the LMS is down), Operator (the course cannot be recovered without editing content).
No UI, configuration, or migration changes.
Supporting information
#39131
We hit this in production on a course holding a block of type
p, a bare<p>element promoted to block level by an OLX import. Every learner-facing courseware API for that course returned 500 continuously for over 19 hours at roughly 650 failures an hour: course outline, courseware metadata, dates, progress, discussion topics, course blocks, andrecalculate_subsection_grade_v3.update_course_in_cache_v2failed on the same traversal, so the CMS could not rebuild the structure either. We have seen the same crash with a different missing block type (ubcpi), so it is not specific to one block type.Testing instructions
Automated:
pytest lms/djangoapps/course_blocks/transformers/tests/test_library_content.pytest_collect_skips_block_with_uninstalled_typepatchesXBlock.load_classto raise only when called without a default, which is the distinction that causes the bug: the modulestore passes itsdefault_classand gets aHiddenBlock, whilecollect()passed none and raised. It assertscollect()completes and the skipped item bank's children get no analytics summary.test_collect_summarizes_when_type_is_installedis the positive control: unchanged behaviour when the type loads.LoadBlockClassTestCasecovers the helper directly.Manual:
block_typehas no installed XBlock. The quickest route is an OLX import where a bare<p>element sits where a child block is expected../manage.py lms generate_course_blocks --courses <course_id> --force_update./api/course_home/outline/<course_id>.Before this change step 2 fails with
PluginMissingErrorand step 3 returns 500 on every request. After it, step 2 succeeds, the outline renders, and the LMS logs a warning naming the skipped block and its type.Deadline
None.
Other information
No dependencies on other changes.
I have not been able to run the test suite locally, so CI on this PR is the first execution of the new tests.
AmbiguousPluginErrorwould abortcollect()in the same permanent way, but that indicates a packaging conflict rather than bad course data and catching it here would mask a real install problem, so the catch is deliberately narrow. Happy to widen it if reviewers disagree.Worth considering for backport to active releases: an affected course is completely inaccessible to learners and cannot be recovered from the operator side.