Skip to content

fix: skip blocks with uninstalled XBlock types in item bank collect - #39132

Open
blarghmatey wants to merge 2 commits into
openedx:masterfrom
mitodl:tmacey/guard-uninstalled-xblock-type-in-item-bank-collect
Open

blarghmatey wants to merge 2 commits into
openedx:masterfrom
mitodl:tmacey/guard-uninstalled-xblock-type-in-item-bank-collect

Conversation

@blarghmatey

Copy link
Copy Markdown
Contributor

Description

ContentLibraryTransformer.collect() calls XBlock.load_class() on every block during topological traversal, with nothing catching PluginMissingError.

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 (xmodule.hidden_block.HiddenBlock by default), so such a 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. 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 returns None when the XBlock is not installed, and routes the module's four load_class call sites through it. The collect() 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 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.

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, and recalculate_subsection_grade_v3. update_course_in_cache_v2 failed 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.py

  • test_collect_skips_block_with_uninstalled_type patches XBlock.load_class to raise only when called without a default, which is the distinction that causes the bug: the modulestore passes its default_class and gets a HiddenBlock, while collect() passed none and raised. It asserts collect() completes and the skipped item bank's children get no analytics summary.
  • test_collect_summarizes_when_type_is_installed is the positive control: unchanged behaviour when the type loads.
  • LoadBlockClassTestCase covers the helper directly.

Manual:

  1. In a course, create a block whose block_type has no installed XBlock. The quickest route is an OLX import where a bare <p> element sits where a child block is expected.
  2. Force a rebuild of the course's block structure: ./manage.py lms generate_course_blocks --courses <course_id> --force_update.
  3. As an enrolled learner, request /api/course_home/outline/<course_id>.

Before this change step 2 fails with PluginMissingError and 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.

AmbiguousPluginError would abort collect() 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.

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Sep 21, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @blarghmatey!

This repository is currently maintained by @openedx/wg-maintenance-openedx-platform-oncall.

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 approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To 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:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where 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:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@blarghmatey

Copy link
Copy Markdown
Contributor Author

@openedx/wg-maintenance-openedx-platform-oncall this is ready for engineering review. Build is green.

Short version: ContentLibraryTransformer.collect() is the one place that calls XBlock.load_class() without a default. Everywhere else an unknown block type resolves to the modulestore's default_class and becomes a HiddenBlock, so the course keeps working. Here it raises, which aborts the block structure build for the whole course, and since nothing then gets written to BlockStructureModel every later request repeats the failure. The course is inaccessible until someone edits the content.

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 returned 500 continuously at roughly 650/hr, and update_course_in_cache_v2 failed on the same traversal so it could not self-heal.

Two things worth a reviewer's attention:

  • I routed all four load_class call sites in the module through the new helper. Only two of the existing block_class is None guards actually become reachable; the other two sit behind an earlier guard on the same block type. Happy to drop those redundant reloads instead if you'd rather keep the diff to the one broken call site.
  • The catch is narrow, PluginMissingError only. AmbiguousPluginError would break collect() 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. Say the word if you disagree.

I flagged this for backport consideration in the description since an affected course is completely inaccessible and cannot be recovered from the operator side.

@mphilbrick211 mphilbrick211 moved this from Needs Triage to Ready for Review in Contributions Sep 22, 2026
@mphilbrick211 mphilbrick211 added the needs reviewer assigned PR needs to be (re-)assigned a new reviewer label Sep 22, 2026
blarghmatey and others added 2 commits October 1, 2026 09:03
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
@blarghmatey
blarghmatey force-pushed the tmacey/guard-uninstalled-xblock-type-in-item-bank-collect branch from 6791937 to b4c088e Compare October 1, 2026 13:03

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs reviewer assigned PR needs to be (re-)assigned a new reviewer open-source-contribution PR author is not from Axim or 2U

Projects

Status: Ready for Review

Development

Successfully merging this pull request may close these issues.

3 participants