diff --git a/CHANGES.md b/CHANGES.md index 4c026a25..fd9a3889 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,30 @@ Release Notes +Release 4.4.0.02 (Feature/completion-cache) + +Performance: +* Added completion state caching to eliminate redundant DB queries during grade + recalculation and completion reports. + +Problem: +* The function `questionnaire_get_completion_state()` executed 2 DB queries per check (load questionnaire record + check response existence). During grade recalculation, this is called for every student on every activity that has an availability condition based on questionnaire completion. On a course with 500 students and 45 dependent activities, this produced ~46,000 queries per regrade. + At 100,000 users this would reach ~9,000,000 queries. + +Solution: +* [SPECAPPS-205] New class `\mod_questionnaire\completion_cache` provides two layers of caching: + - Questionnaire record cache: loads each questionnaire's settings once per request, eliminating repeated identical queries across users. + - Lazy bulk preload: on first completion check for a questionnaire, loads ALL completed user IDs in a single `SELECT DISTINCT userid` query. All subsequent checks for any user on the same questionnaire are PHP array lookups with zero DB queries. + +Cache invalidation: +* Cache is invalidated on response submit (both initial and resume paths) and response delete, ensuring correctness when completion state changes mid-request. + +Files changed: +* classes/completion_cache.php (new) - cache class with check(), clear(), invalidate() methods +* lib.php - questionnaire_get_completion_state() delegates to cache +* questionnaire.class.php - cache invalidation on submit +* locallib.php - cache invalidation on delete +* tests/custom_completion_test.php - setUp() added to clear cache between tests + Release 4.4.0 (Build - 2025110900) New Features: * [PR590](https://github.com/PoetOS/moodle-mod_questionnaire/pull/590): Allow responses to be deleted automatically after a specified time. This is disabled by default. diff --git a/classes/completion_cache.php b/classes/completion_cache.php new file mode 100644 index 00000000..16507ed0 --- /dev/null +++ b/classes/completion_cache.php @@ -0,0 +1,101 @@ +. + +namespace mod_questionnaire; + +/** + * Caches questionnaire completion state to avoid repeated DB queries. + * + * On first check for a questionnaire, bulk-loads all completed user IDs + * for that questionnaire into memory. Subsequent checks for any user on + * the same questionnaire are served from the cache with zero DB queries. + * + * Also caches questionnaire records so the same instance isn't loaded + * repeatedly across different users. + * + * @package mod_questionnaire + * @copyright 2026 Jamie Burgess, NSW Department of Education + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class completion_cache { + + /** @var array Questionnaire records keyed by instance ID. */ + private static $questionnaires = []; + + /** @var array Completed user sets keyed by questionnaire ID. Each value is userid => true. */ + private static $completed = []; + + /** + * Check whether a user has completed a questionnaire. + * + * @param \stdClass $cm Course module object. + * @param int $userid User ID to check. + * @param mixed $type Completion type (returned if completionsubmit is disabled). + * @return bool|mixed True if completed, false if not, $type if completion not enabled. + */ + public static function check($cm, int $userid, $type) { + global $DB; + + $instanceid = $cm->instance; + + // Load questionnaire record from cache or DB. + if (!isset(self::$questionnaires[$instanceid])) { + self::$questionnaires[$instanceid] = $DB->get_record('questionnaire', + ['id' => $instanceid], '*', MUST_EXIST); + } + $questionnaire = self::$questionnaires[$instanceid]; + + if (!$questionnaire->completionsubmit) { + return $type; + } + + $qid = $questionnaire->id; + + // Lazy bulk preload: on first check for this questionnaire, + // load ALL completed user IDs in a single query. + if (!isset(self::$completed[$qid])) { + self::$completed[$qid] = []; + $records = $DB->get_records_sql( + 'SELECT DISTINCT userid FROM {questionnaire_response} WHERE questionnaireid = ? AND complete = ?', + [$qid, 'y']); + foreach ($records as $record) { + self::$completed[$qid][$record->userid] = true; + } + } + + return isset(self::$completed[$qid][$userid]); + } + + /** + * Clear all caches. + * + * Call after any operation that changes questionnaire responses + * (submit, delete) within the same request. + */ + public static function clear(): void { + self::$questionnaires = []; + self::$completed = []; + } + + /** + * Invalidate the completion cache for a specific questionnaire. + * + * @param int $questionnaireid The questionnaire ID to invalidate. + */ + public static function invalidate(int $questionnaireid): void { + unset(self::$completed[$questionnaireid]); + } +} diff --git a/lib.php b/lib.php index 8b2c5fc8..230b61a3 100644 --- a/lib.php +++ b/lib.php @@ -1232,19 +1232,7 @@ function questionnaire_reset_userdata($data) { * */ function questionnaire_get_completion_state($cm, $userid, $type) { - global $DB; - - // Get questionnaire details. - $questionnaire = $DB->get_record('questionnaire', array('id' => $cm->instance), '*', MUST_EXIST); - - // If completion option is enabled, evaluate it and return true/false. - if ($questionnaire->completionsubmit) { - $params = ['userid' => $userid, 'questionnaireid' => $questionnaire->id, 'complete' => 'y']; - return $DB->record_exists('questionnaire_response', $params); - } else { - // Completion option is not enabled so just return $type. - return $type; - } + return \mod_questionnaire\completion_cache::check($cm, $userid, $type); } /** diff --git a/locallib.php b/locallib.php index 4685b6fa..4371aca3 100644 --- a/locallib.php +++ b/locallib.php @@ -403,6 +403,9 @@ function questionnaire_delete_response($response, $questionnaire='') { $status = $status && $DB->delete_records('questionnaire_response', array('id' => $rid)); if ($status && $cm) { + // Invalidate completion cache after response deletion. + \mod_questionnaire\completion_cache::invalidate($questionnaire->id); + // Update completion state if necessary. $completion = new completion_info($questionnaire->course); if ($completion->is_enabled($cm) == COMPLETION_TRACKING_AUTOMATIC && $questionnaire->completionsubmit) { diff --git a/questionnaire.class.php b/questionnaire.class.php index b117e281..91908e4e 100644 --- a/questionnaire.class.php +++ b/questionnaire.class.php @@ -321,6 +321,9 @@ public function view() { $this->update_grades($quser); + // Invalidate completion cache before updating state. + \mod_questionnaire\completion_cache::invalidate($this->id); + // Update completion state. $completion = new completion_info($this->course); if ($completion->is_enabled($this->cm) && $this->completionsubmit) { @@ -376,6 +379,9 @@ public function commit_submission_response($rid, $quser) { $this->update_grades($quser); + // Invalidate completion cache before updating state. + \mod_questionnaire\completion_cache::invalidate($this->id); + // Update completion state. $completion = new \completion_info($this->course); if ($completion->is_enabled($this->cm) && $this->completionsubmit) { diff --git a/tests/custom_completion_test.php b/tests/custom_completion_test.php index e46ce656..de010008 100644 --- a/tests/custom_completion_test.php +++ b/tests/custom_completion_test.php @@ -46,6 +46,14 @@ */ class custom_completion_test extends \advanced_testcase { + /** + * Clear completion cache between tests. + */ + public function setUp(): void { + parent::setUp(); + \mod_questionnaire\completion_cache::clear(); + } + /** * Data provider for get_state(). * diff --git a/version.php b/version.php index 68c2822e..eb631342 100644 --- a/version.php +++ b/version.php @@ -25,10 +25,10 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024080100.01; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2024080100.02; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2024042200.00; // Moodle version (4.4.0). $plugin->component = 'mod_questionnaire'; -$plugin->release = '4.4.0 (Build - 2025110900)'; +$plugin->release = '4.4.0.02 (Feature/completion-cache)'; $plugin->maturity = MATURITY_STABLE;