Skip to content

Cache completion state to eliminate redundant DB queries#755

Open
jamieburgess wants to merge 1 commit into
PoetOS:MOODLE_404_STABLEfrom
jamieburgess:feature/cache-completion
Open

Cache completion state to eliminate redundant DB queries#755
jamieburgess wants to merge 1 commit into
PoetOS:MOODLE_404_STABLEfrom
jamieburgess:feature/cache-completion

Conversation

@jamieburgess
Copy link
Copy Markdown

@jamieburgess jamieburgess commented May 18, 2026

Completion State Caching for mod_questionnaire

Summary

Adding a static in-memory cache for questionnaire completion state checks, eliminates redundant database queries during grade recalculation and completion report generation.

Perfomance Issue at scale

The function questionnaire_get_completion_state() is called every time Moodle needs to check whether a user has completed a questionnaire. Each call executes 2 DB queries:

  1. get_record('questionnaire', ...) - loads the questionnaire settings
  2. record_exists('questionnaire_response', ...) — checks if the user has a completed response

This is fine for a single check, but becomes a bottleneck in bulk operations:

  • Grade recalculation: When grade items are marked as needing update, Moodle recalculates grades for every student. Activities with availability conditions based on questionnaire completion trigger a completion check for each student per dependent activity — common in courses that gate later content on a pre-survey or consent questionnaire.

  • Completion reports: Progress reports and course completion reports evaluate completion for all students across all activities.

Real-world impact (measured on test data)

The figures below are from a course that uses questionnaire completion as an availability restriction extensively (e.g. a consent/intake questionnaire gating most subsequent activities). The cost scales as 'students × gated activities × 2 queries', so sites with lighter use of questionnaire-based gating will see the same percentage reduction (just at smaller scale).

Scenario Students Dependent activities DB queries (before) DB queries (after)
Grade regrade 510 45 46,260 40
Grade regrade 100,000 45 ~9,000,000 40

Benchmark results

Metric Before After Improvement
DB reads (regrade) 7,196 278 96.1% reduction
Time (regrade). 0.98s 0.13s 86.3% faster

Solution

This PR adds \mod_questionnaire\completion_cache — a static in-memory cache with two layers:

1. Questionnaire record cache

Caches the questionnaire DB record by instance ID. Since the 'completionsubmit' setting is the same for all users in a questionnaire, this eliminates repeated identical queries.

2. Lazy bulk preload of completion status

On the first completion check for a given questionnaire, executes a single query:

SELECT DISTINCT userid FROM {questionnaire_response}
WHERE questionnaireid = ? AND complete = 'y'

This loads all completed user IDs for that questionnaire into a PHP array. All subsequent checks for any user on the same questionnaire are simple isset() lookups — zero DB queries.

Memory usage

Below are sample measurements of memory usage at scale:

  • Each completed user ID in the cache uses ~64 bytes
  • 100,000 completed users = ~6.4 MB per questionnaire
  • 20 questionnaires in a course = ~128 MB worst case
  • Cache is freed at end of each HTTP request

Cache invalidation

The cache is invalidated at all points where questionnaire response data changes:

Location Event Method
questionnaire.class.php (line ~324) Response submitted (initial) completion_cache::invalidate($id)
questionnaire.class.php (line ~382) Response submitted (resume) completion_cache::invalidate($id)
locallib.php (line ~407) Response deleted completion_cache::invalidate($id)

The invalidate() method removes only the specific questionnaire's cache, not all cached data.

A full clear() method is also available for use in testing or maintenance contexts.

Safety analysis

Stale cache risk

Scenario Risk Assessment
Student submits response Submit path calls update_state(COMPLETION_COMPLETE) which writes directly, bypassing get_state() Safe — cache is invalidated before update_state
Response deleted Delete path calls update_state(COMPLETION_INCOMPLETE) Safe — cache is invalidated before update_state
Bulk report (many users) Each user checked once Target use case — one query replaces thousands.
Course page (one user) Moodle's own completion_info cache handles per-user caching Safe — our cache adds minimal overhead
Availability conditions Moodle reads completion via completion_info::get_data() which may call get_state() Safe — tested with chained availability conditions

PHPUnit considerations

Static caches persist across test methods within a single test class. A setUp() method was added to custom_completion_test to clear the cache between tests, preventing cross-test contamination.

Files changed

File Change
classes/completion_cache.php New — cache class with check(), clear(), invalidate() methods
lib.php questionnaire_get_completion_state() delegates to completion_cache::check()
questionnaire.class.php Cache invalidation added at both response submit paths
locallib.php Cache invalidation added at response delete path
tests/custom_completion_test.php setUp() added to clear cache between tests

Testing

  • All 53 existing PHPUnit tests pass (398 assertions)
  • Completion state verified against direct DB queries for correctness
  • Availability condition chains tested (questionnaire completion gates assignment access)
  • Cache hit/miss behaviour verified (first call = 2 DB reads, subsequent = 0)

How to verify

Functional testing (UI)

  1. Setup: Use a course that has questionnaires with completion tracking enabled ("Student must submit this questionnaire to complete it") and other activities that require questionnaire completion as an access restriction.

  2. Test completion still works:

    • Log in as a student
    • Submit a questionnaire that gates other activities
    • Verify the dependent activities become available after submission
    • Check the activity completion tick appears on the course page
  3. Test completion reports:

    • Log in as a teacher/admin
    • Navigate to Course > Reports > Activity completion
    • Verify questionnaire completion states display correctly for all students
    • Navigate to Course > Reports > Course completion and verify it loads without errors
  4. Test grade regrade:

    • As an admin, grade an assignment in the course
    • Navigate to the course gradebook
    • Verify it loads without errors and grade totals are correct
    • Check that the gradebook page loads in a reasonable time (should be noticeably faster than before on courses with many students and questionnaire-based restrictions)
  5. Test response deletion:

    • As a teacher, delete a student's questionnaire response
    • Verify the student's completion state updates (tick removed from course page)
    • Verify the dependent activities become restricted again for that student

Performance testing (optional, requires admin access)

This test measures the reduction in database queries. You will need a course with 100+ enrolled students, questionnaires with completion enabled, and activities restricted by questionnaire completion.

  1. Setup

    • Use a course that has questionnaires with completion tracking enabled ("Student must submit this questionnaire to complete it") and other activities that require questionnaire completion as an access restriction.
    • Ideally, this course should have hundreds of users with hundreds of assignments with submissions.
  2. Enable the Excimer profiler for a specific page:

    • Ensure the Excimer profiler plugin is installed (Site admin > Plugins > Admin tools > Excimer profiler)
    • Append &FLAMEME=1 to the URL of the page you want to profile (e.g. the gradebook or activity completion report)
    • This captures a profile for that single page load
  3. Trigger a grade recalculation:

    • Set all grade items to require a regrade using a query like the below:
      UPDATE mdl_grade_items SET needsupdate = 1 WHERE courseid = 123;
    • Navigate to the course gradebook or the assign/grader page — this triggers a regrade for any stale grade items
  4. View the profiler results:

    • Go to Site admin > Plugins > Admin tools > Excimer profiler > Slowest web requests
    • Find the gradebook page load or the assign/grader page
    • Click into the profile to see the DB reads count
  5. Compare before and after:

    • On the branch without this change, a course with 500 students and 45 questionnaire-dependent activities will show approximately 46,000+ DB reads during a full regrade
    • On the branch with this change, the same operation should show approximately 300 DB reads or fewer
    • The page load time should also be significantly faster (measured at 86% faster in benchmarks)
  6. Alternative — use Moodle debug footer:

    • If Excimer is not available, enable Site admin > Development > Debugging > Performance info ($CFG->perfdebug = 15)
    • The page footer will show total DB reads for each page load
    • Compare the DB reads on the gradebook or activity completion report page before and after the change

@mchurchward, is this something you can review? Thank you!

Introduces \mod_questionnaire\completion_cache class that bulk-loads all
completed user IDs per questionnaire on first check, then serves subsequent
checks from memory. Also caches questionnaire records to avoid repeated loads.

Problem: questionnaire_get_completion_state() executed 2 DB queries per check.
During grade recalculation with availability conditions based on questionnaire
completion, this produced ~46,000 queries for 500 students (45 dependent
activities). At 100,000 users this would reach ~9,000,000 queries.

Solution: Lazy bulk preload loads all completed user IDs in a single query.
All subsequent checks are PHP array lookups with zero DB queries.

Benchmarks (510 students, 45 dependent activities):
- DB reads during regrade: 7,196 -> 278 (96.1% reduction)
- Regrade time: 0.98s -> 0.13s (86.3% faster)
- Completion queries: 46,260 -> 40 (99.91% reduction)

Cache is invalidated on response submit and delete.
Version bumped to 4.4.0.02.
@jamieburgess jamieburgess force-pushed the feature/cache-completion branch from d6a12f0 to 79c072f Compare May 18, 2026 06:19
@jamieburgess jamieburgess marked this pull request as ready for review May 18, 2026 06:24
Copy link
Copy Markdown
Contributor

@mchurchward mchurchward left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the pull request. I do not accept any improvements to the 400 series any more. The current active branch is MOODLE_500_STABLE. This, as a minimum, needs to be based off of that. Also, while I appreciate the modification to CHANGES.md, that file gets updated only when a new version is released.
Note that I may requires this to be redone on the new codebase, currently under construction.

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.

2 participants