Skip to content

feat: incrementally load large assessment units - #39298

Open
abhalsod-sonata wants to merge 4 commits into
openedx:masterfrom
edx:abhalsod/incremental-assessment-load
Open

abhalsod-sonata wants to merge 4 commits into
openedx:masterfrom
edx:abhalsod/incremental-assessment-load

Conversation

@abhalsod-sonata

@abhalsod-sonata abhalsod-sonata commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Description

Very large assessment units, for example a 90-question mock exam where every question comes from a randomized question bank, can take longer to render in a single response than the request timeout allows, so the unit never loads.

This PR adds incremental loading for those units, behind a new course waffle flag, courseware.incremental_assessment_load (opt-in per course). With the flag on, a unit with more than INCREMENTAL_LOAD_PROBLEM_THRESHOLD (20) problems:

  1. Renders its first INCREMENTAL_LOAD_EAGER_COUNT (5) children inline and leaves placeholders for the rest.
  2. Fills the placeholders from the new GET /api/courseware/xblock_children/{usage_key}?child_usage_keys=... endpoint, in sequential batches of at most XBLOCK_CHILDREN_BATCH_MAX (10). The page shows "Loading question X of Y...", retries transient failures with backoff, halves a batch that times out, and offers a Retry button for any child that fails.

Units at or below the threshold, and requests from the mobile apps, always render in full.

The PR is four commits:

  1. feat: incrementally load large assessment units: the flag, settings, endpoint, vertical shell and page script.
  2. fix: hydrate learner state for incrementally loaded children: lazily loaded problems now show the learner's saved answer, score and attempts. Without this, an answered problem rendered as a fresh attempt and resubmitting it failed with "The state of this problem has changed since you loaded this page". One FieldDataCache is populated per batch (new FieldDataCache.add_block_descendents_batch), so a batch costs a single learner-state lookup.
  3. fix: hydrate item bank problems and staff debug in lazily loaded units: the same fix for problems inside randomized banks (item_bank / library_content), which are loaded through the shared runtime the parent bound. It also makes Staff Debug Info and Submission History work for lazily loaded children, by inserting them with edx.HtmlUtils.setHtml so their inline setup scripts run, the same as an inline render.
  4. docs: annotate incremental assessment load settings.

User roles impacted:

  • Learner: large exams in courses with the flag on load reliably; the rest of the questions appear a moment after the page opens.
  • Operator: a new opt-in course waffle flag and three settings, all annotated (lms/djangoapps/courseware/toggles.py, lms/envs/common.py).
  • Developer: a new courseware API endpoint, xblock_children.

Before vs after

Measured on a local devstack with a 90-question unit, each question an item bank holding one problem, warm cache. The absolute numbers depend on the machine; the comparison is what matters.

Before: flag off (current master behavior) After: flag on
First response 6.0 to 6.3 s server time, 2.9 MB HTML, all 90 problems 2.6 to 2.7 s server time, 241 KB HTML, 5 problems and 85 placeholders
Follow-up requests None 9 batch requests of up to 10 children, each 1.1 s or less
All 90 questions on screen About 8 s About 10 s

The total time to show every question stays about the same. The difference is that no single request has to render the whole unit, and the learner can start right after the first response.

1. Before (flag off): the whole 90-question unit arrives in a single 2.9 MB response.
image

2. After (flag on): the page arrives with the first 5 questions; questions 6 to 90 are placeholders.
image

3. After: the rest load in batches while the status line reports progress. The batch responses were slowed down in the browser to capture this state.
image

4. After: Staff Debug Info on question 7, a lazily loaded problem inside an item bank, opens.
image

5. After: question 7 answered and the page reloaded: the saved answer, the score (1/1 point) and the attempts (1 of 1) are kept, and Submit stays disabled.
image

Supporting information

The original feature and the first fix were written by Santhosh Kumar; this PR ports them, with the follow-up fix, onto master.

Testing instructions

  1. Create a course with a unit that has more than 20 problems. To cover the bank case, make each question a randomized content block (item bank) that picks one problem.
  2. With the flag off, open the unit as a learner: it renders in full, as before.
  3. In Django admin, add a waffle flag course override for courseware.incremental_assessment_load, forced on, for that course.
  4. Reload the unit:
    • The first 5 questions arrive with the page.
    • The rest show "Loading question X of Y..." and fill in.
    • The network tab shows GET /api/courseware/xblock_children/<unit key>/?child_usage_keys=... requests with at most 10 keys each.
  5. Answer a question after Q5 and refresh the page: the answer, score and attempts are still shown, and submitting again does not show "The state of this problem has changed...".
  6. As course staff, click Staff Debug Info and Submission history under a question after Q5: both open.
  7. Units with 20 or fewer problems, and mobile app requests, still render in full with the flag on.

Automated tests:

  • lms/djangoapps/courseware/tests/test_incremental_render.py: the threshold and flag decision, the shell split, the endpoint helpers.
  • openedx/core/djangoapps/courseware_api/tests/test_views.py (XBlockChildrenApiTestViews):
    • batch rendering, auth, 400 cases and partial failure;
    • the security boundary: only the parent's own, learner-visible children are served;
    • persisted state for plain problems and for problems inside an item bank.
  • lms/djangoapps/courseware/tests/test_model_data.py: add_block_descendents_batch populates the cache once per batch.

Deadline

None.

Other information

  • No migrations. The new endpoint uses JWT or session authentication and requires an enrolled learner with access to the parent unit. It only serves the parent's own children that are visible to that learner. For a randomized bank that means only the learner's assigned problems, never the whole pool.
  • Security: child HTML comes from this LMS's own render of the same blocks the eager path inlines. The page script inserts it with edx.HtmlUtils.setHtml(el, edx.HtmlUtils.HTML(html)), the same idiom capa's display.js uses for server-rendered problem HTML.
  • Not included: the downstream prefetch narrowing for randomized banks (edx/edx-platform@04e7ed024f). Without it, a batch prefetches each bank's whole pool, which is what master does today. It is independent of this feature and can be proposed separately.
  • Locally verified with Python 3.12 and the uv.lock dependencies:
    • Tests: test_incremental_render.py, courseware_api/tests/test_views.py, test_model_data.py, xmodule/tests/test_vertical.py,
      lms/djangoapps/courseware/tests/test_block_render.py and lms/djangoapps/courseware/tests/test_views.py (496 passed).
    • Checks: ruff check ., make xsslint, make pii_check, make check_keywords, make lint-imports, make check-types, and pylint on the changed files.

Rendering every problem of a very large unit, such as a 90-question
exam built from randomized question banks, in one response can exceed
the upstream request timeout, so the unit never loads.

Behind the new course waffle flag courseware.incremental_assessment_load
(off by default), a unit with more than INCREMENTAL_LOAD_PROBLEM_THRESHOLD
problems renders its first INCREMENTAL_LOAD_EAGER_COUNT children inline
and leaves placeholders for the rest. The page then fills them in, in
sequential batches of at most XBLOCK_CHILDREN_BATCH_MAX, from the new
GET /api/courseware/xblock_children/{usage_key} endpoint, retrying
transient failures. Mobile app requests and units at or below the
threshold always render in full.

With the flag off, rendering is unchanged.

Ported from #445, originally written by Santhosh Kumar.
Children rendered by the xblock_children endpoint did not see the
learner's saved state, so an answered problem rendered as a fresh
attempt and resubmitting it failed with "The state of this problem has
changed since you loaded this page".

Resolve each requested child to a fresh, unbound descriptor and bind it
once, after one FieldDataCache populated for the whole batch through the
new FieldDataCache.add_block_descendents_batch, so a batch costs a single
learner-state lookup. The page now also shows a per-child error with a
Retry button for children the endpoint reports in its errors list.

Ported from #465, originally written by Santhosh Kumar.
Problems a randomized bank (item_bank / library_content) selected rendered
as unanswered when they fell past the incremental-load eager window. The
bank's selected problem is loaded through the runtime the parent vertical
bound with its own depth-0 FieldDataCache, so it never saw the learner
state prefetched into the separate batch cache. Resubmitting such a problem
then failed with "The state of this problem has changed since you loaded
this page".

Bind the parent and the batch to one FieldDataCache so every block loaded
through the shared runtime sees the same state.

Lazily inserted children were also set with innerHTML, which never runs
inline scripts, so setup_debug() from staff_problem_info.html never ran and
Staff Debug Info / Submission History did nothing past the eager window.
Insert them with edx.HtmlUtils.setHtml, which runs them as an inline render
does and marks the server-rendered fragment trusted the same way capa does.

Ported from #529.
Use the standard setting annotations for INCREMENTAL_LOAD_PROBLEM_THRESHOLD,
INCREMENTAL_LOAD_EAGER_COUNT and XBLOCK_CHILDREN_BATCH_MAX so they show up in
the generated settings documentation.
@abhalsod-sonata
abhalsod-sonata force-pushed the abhalsod/incremental-assessment-load branch from ea796f4 to 5386736 Compare October 23, 2026 13:23

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant