MDL-86493 mod_quiz: Change overrides cache from O(n*m) to O(n)

This commit is contained in:
Alexander Van der Bellen
2026-03-12 22:38:02 +08:00
parent 6ebf41a6c2
commit ff4dce5d0d
14 changed files with 702 additions and 399 deletions
@@ -0,0 +1,26 @@
issueNumber: MDL-86493
notes:
mod_quiz:
- message: >-
The quiz overrides cache implementation has been replaced with a faster alternative with a different API.
This should be a transparent change but any direct references will still need to be updated.
type: deprecated
- message: >
`mod_quiz_cm_info_dynamic()` now uses the new `quiz_overrides` cache via `override_manager`, performing a single cache fetch per quiz/user.
This significantly reduces cache calls on course pages with many quizzes and groups.
The new `mod_quiz:quiz_overrides` cache is keyed by `quizid_userid` using datasource `\mod_quiz\cache\quiz_overrides_cache`.
This cache returns all applicable overrides for a user in a quiz (the user override, if any, plus all group overrides for groups they belong to in the quiz's course).
New class `\mod_quiz\local\quiz_overrides_cache_manager` to interact with the cache:
- `get_overrides(int $quizid, int $userid): array`
- `purge_for_user(int $quizid, int $userid): void`
- `purge_for_users(int $quizid, array $userids): void`
- `purge_for_group(int $quizid, int $groupid): void`
- `purge_for_group_members(int $groupid, array $userids): void`
Hook callbacks in `db/hooks.php` to keep the cache in sync with group
membership changes:
- `\core_group\hook\after_group_membership_added`
- `\core_group\hook\after_group_membership_removed`
type: improved
-115
View File
@@ -1,115 +0,0 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Cache data source for the quiz overrides.
*
* @package mod_quiz
* @copyright 2021 Shamim Rezaie <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
declare(strict_types=1);
namespace mod_quiz\cache;
use core_cache\data_source_interface;
use core_cache\definition;
/**
* Class quiz_overrides
*
* @package mod_quiz
* @copyright 2021 Shamim Rezaie <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class overrides implements data_source_interface {
/** @var overrides the singleton instance of this class. */
protected static $instance = null;
/**
* Returns an instance of the data source class that the cache can use for loading data using the other methods
* specified by this interface.
*
* @param definition $definition
* @return overrides
*/
public static function get_instance_for_cache(definition $definition): overrides {
if (is_null(self::$instance)) {
self::$instance = new overrides();
}
return self::$instance;
}
/**
* Loads the data for the key provided ready formatted for caching.
*
* @param string|int $key The key to load.
* @return mixed What ever data should be returned, or false if it can't be loaded.
* @throws \coding_exception
*/
public function load_for_cache($key) {
global $DB;
// Ignore getting data if this is a cache invalidation - {@see \core_cache\helper::purge_by_event()}.
if ($key == 'lastinvalidation') {
return null;
}
[$quizid, $ug, $ugid] = explode('_', $key);
$quizid = (int) $quizid;
switch ($ug) {
case 'u':
$userid = (int) $ugid;
$override = $DB->get_record(
'quiz_overrides',
['quiz' => $quizid, 'userid' => $userid],
'timeopen, timeclose, timelimit, attempts, password'
);
break;
case 'g':
$groupid = (int) $ugid;
$override = $DB->get_record(
'quiz_overrides',
['quiz' => $quizid, 'groupid' => $groupid],
'timeopen, timeclose, timelimit, attempts, password'
);
break;
default:
throw new \coding_exception('Invalid cache key');
}
// Return null instead of false, because false will not be cached.
return $override ?: null;
}
/**
* Loads several keys for the cache.
*
* @param array $keys An array of keys each of which will be string|int.
* @return array An array of matching data items.
*/
public function load_many_for_cache(array $keys) {
$results = [];
foreach ($keys as $key) {
$results[] = $this->load_for_cache($key);
}
return $results;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
namespace mod_quiz\cache;
use core_cache\data_source_interface;
use core_cache\definition;
/**
* Data source implementation for the new quiz_overrides cache.
*
* This loads all applicable overrides for a (quizid, userid) pair:
* - The user override (if present)
* - All group overrides for groups the user belongs to in the quiz's course
*
* @package mod_quiz
* @copyright 2025 Catalyst IT Australia Pty Ltd
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_overrides_cache implements data_source_interface {
/** @var ?quiz_overrides_cache Singleton instance. */
private static $instance = null;
#[\Override]
public static function get_instance_for_cache(definition $definition): quiz_overrides_cache {
return self::$instance ??= new quiz_overrides_cache();
}
#[\Override]
public function load_for_cache($key) {
global $DB;
// Core cache invalidation asks datasources for this internal key.
if ($key === 'lastinvalidation') {
return false;
}
// All regular keys use the "{quizid}_{userid}" format.
[$quizid, $userid] = self::split_cache_key((string) $key);
$subquery = "SELECT g.id
FROM {groups} g
JOIN {groups_members} gm ON gm.groupid = g.id
JOIN {quiz} q ON q.course = g.courseid
WHERE q.id = :subqueryquizid AND gm.userid = :subqueryuserid";
$sql = "SELECT *
FROM {quiz_overrides}
WHERE quiz = :quizid AND (userid = :userid OR groupid IN ($subquery))";
return $DB->get_records_sql($sql, [
'quizid' => $quizid,
'userid' => $userid,
'subqueryquizid' => $quizid,
'subqueryuserid' => $userid,
]);
}
#[\Override]
public function load_many_for_cache(array $keys) {
$results = [];
foreach ($keys as $key) {
$results[$key] = $this->load_for_cache($key);
}
return $results;
}
/**
* Split a cache key into its quizid and userid components.
*
* @param string $key The cache key.
* @return array{0:int,1:int} An array with quizid and userid as integers.
*/
private static function split_cache_key(string $key): array {
return array_map('intval', explode('_', $key));
}
}
@@ -1,126 +0,0 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_quiz\local;
/**
* Cache manager for quiz overrides
*
* Override cache data is set via its data source, {@see \mod_quiz\cache\overrides}
* @package mod_quiz
* @copyright 2024 Matthew Hilton <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class override_cache {
/** @var string invalidation event used to purge data when reset_userdata is called, {@see \cache_helper::purge_by_event()} **/
public const INVALIDATION_USERDATARESET = 'userdatareset';
/**
* Create override_cache object and link to quiz
*
* @param int $quizid The quiz to link this cache to
*/
public function __construct(
/** @var int $quizid ID of quiz cache is being operated on **/
protected readonly int $quizid
) {
}
/**
* Returns the override cache
*
* @return \cache
*/
protected function get_cache(): \cache {
return \cache::make('mod_quiz', 'overrides');
}
/**
* Returns group cache key
*
* @param int $groupid
* @return string the group cache key
*/
protected function get_group_cache_key(int $groupid): string {
return "{$this->quizid}_g_{$groupid}";
}
/**
* Returns user cache key
*
* @param int $userid
* @return string the user cache key
*/
protected function get_user_cache_key(int $userid): string {
return "{$this->quizid}_u_{$userid}";
}
/**
* Returns the override value in the cache for the given group
*
* @param int $groupid group to get cached override data for
* @return ?\stdClass override value in the cache for the given group, or null if there is none.
*/
public function get_cached_group_override(int $groupid): ?\stdClass {
$raw = $this->get_cache()->get($this->get_group_cache_key($groupid));
return empty($raw) || !is_object($raw) ? null : (object) $raw;
}
/**
* Returns the override value in the cache for the given user
*
* @param int $userid user to get cached override data for
* @return ?\stdClass the override value in the cache for the given user, or null if there is none.
*/
public function get_cached_user_override(int $userid): ?\stdClass {
$raw = $this->get_cache()->get($this->get_user_cache_key($userid));
return empty($raw) || !is_object($raw) ? null : (object) $raw;
}
/**
* Deletes the cached override data for a given group
*
* @param int $groupid group to delete data for
*/
public function clear_for_group(int $groupid): void {
$this->get_cache()->delete($this->get_group_cache_key($groupid));
}
/**
* Deletes the cached override data for the given user
*
* @param int $userid user to delete data for
*/
public function clear_for_user(int $userid): void {
$this->get_cache()->delete($this->get_user_cache_key($userid));
}
/**
* Clears the cache for the given user and/or group.
*
* @param ?int $userid user to delete data for, or null.
* @param ?int $groupid group to delete data for, or null.
*/
public function clear_for(?int $userid = null, ?int $groupid = null): void {
if (!empty($userid)) {
$this->clear_for_user($userid);
}
if (!empty($groupid)) {
$this->clear_for_group($groupid);
}
}
}
@@ -16,6 +16,9 @@
namespace mod_quiz\local;
use context_course;
use core_group\hook\after_group_membership_added;
use core_group\hook\after_group_membership_removed;
use mod_quiz\event\group_override_created;
use mod_quiz\event\group_override_deleted;
use mod_quiz\event\group_override_updated;
@@ -260,8 +263,12 @@ class override_manager {
$groupid = $datatoset['groupid'] ?? null;
// Clear the cache.
$cache = new override_cache($this->quiz->id);
$cache->clear_for($userid, $groupid);
if (!empty($userid)) {
quiz_overrides_cache_manager::purge_for_user($this->quiz->id, $userid);
}
if (!empty($groupid)) {
quiz_overrides_cache_manager::purge_for_group($this->quiz->id, $groupid);
}
// Trigger moodle events.
if (empty($formdata['id'])) {
@@ -293,7 +300,7 @@ class override_manager {
*/
public function delete_all_overrides(bool $shouldlog = true): void {
global $DB;
$overrides = $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id], '', 'id,userid,groupid');
$overrides = $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id], '', 'id,quiz,userid,groupid');
$this->delete_overrides($overrides, $shouldlog);
}
@@ -313,7 +320,7 @@ class override_manager {
// Filter for those overrides user can access.
[$sql, $params] = self::get_override_in_sql($this->quiz->id, $ids);
$records = array_filter(
$DB->get_records_select('quiz_overrides', $sql, $params, '', 'id,userid,groupid'),
$DB->get_records_select('quiz_overrides', $sql, $params, '', 'id,quiz,userid,groupid'),
fn(\stdClass $override) => $this->can_view_override(
$override,
$quizsettings->get_course(),
@@ -362,6 +369,14 @@ class override_manager {
throw new \coding_exception("All overrides must specify an ID");
}
if (empty($override->quiz)) {
throw new \coding_exception("All overrides must specify a quiz ID");
}
if ($override->quiz != $this->quiz->id) {
throw new \coding_exception("All overrides must belong to the quiz linked to this manager");
}
// Sanity check that user xor group is specified.
// User or group is required to clear the cache.
self::ensure_userid_xor_groupid_set($override->userid ?? null, $override->groupid ?? null);
@@ -376,14 +391,12 @@ class override_manager {
[$sql, $params] = self::get_override_in_sql($this->quiz->id, array_column($overrides, 'id'));
$DB->delete_records_select('quiz_overrides', $sql, $params);
$cache = new override_cache($this->quiz->id);
// Perform other cleanup.
quiz_overrides_cache_manager::purge_for_overrides($overrides);
foreach ($overrides as $override) {
$userid = $override->userid ?? null;
$groupid = $override->groupid ?? null;
$cache->clear_for($userid, $groupid);
$this->delete_override_events($userid, $groupid);
if ($shouldlog) {
@@ -596,6 +609,57 @@ class override_manager {
return $formdata;
}
/**
* Computes the effective overridden open/close times for a user for a given quiz.
*
* @param int $quizid The quiz ID.
* @param int $userid The user ID.
* @return array Array with optional keys 'timeopen' and 'timeclose'.
*/
public static function get_effective_open_close_times(int $quizid, int $userid): array {
$overrides = quiz_overrides_cache_manager::get_overrides($quizid, $userid);
if (empty($overrides)) {
return [];
}
// Get user override (there should be at most one per user per quiz).
$useroverride = array_filter($overrides, fn($o): bool => !empty($o->userid));
$useroverride = reset($useroverride);
$timeopen = empty($useroverride) ? null : $useroverride->timeopen;
$timeclose = empty($useroverride) ? null : $useroverride->timeclose;
// If either value is still null, check group overrides.
if ($timeopen === null || $timeclose === null) {
$groupoverrides = array_filter($overrides, fn($o): bool => !empty($o->groupid));
if (!empty($groupoverrides)) {
$opens = array_filter(array_column($groupoverrides, 'timeopen'), fn($t): bool => $t !== null);
$closes = array_filter(array_column($groupoverrides, 'timeclose'), fn($t): bool => $t !== null);
// Get the earliest open time.
if ($timeopen === null && count($opens)) {
$timeopen = min($opens);
}
// Get the latest close time, unless any are 0 which takes precedence.
if ($timeclose === null && count($closes)) {
$timeclose = in_array(0, $closes) ? 0 : max($closes);
}
}
}
$result = [];
if ($timeopen !== null) {
$result['timeopen'] = $timeopen;
}
if ($timeclose !== null) {
$result['timeclose'] = $timeclose;
}
return $result;
}
/**
* Deletes orphaned group overrides in a given course.
* Note - permissions are not checked and events are not logged for performance reasons.
@@ -620,11 +684,31 @@ class override_manager {
$DB->delete_records_list('quiz_overrides', 'id', array_keys($records));
// Purge cache for each record.
foreach ($records as $record) {
$cache = new override_cache($record->quiz);
$cache->clear_for_group($record->groupid);
// Clear the cache for all users in the course for each quiz that had an orphaned group override.
$quizids = array_unique(array_column($records, 'quiz'));
$userids = array_keys(get_enrolled_users(context_course::instance($courseid), '', 0, 'u.id'));
foreach ($quizids as $quizid) {
quiz_overrides_cache_manager::purge_for_users($quizid, $userids);
}
return array_unique(array_column($records, 'quiz'));
return $quizids;
}
/**
* Hook callback to clear relevant cache entries when a user is added to a group.
*
* @param after_group_membership_added $hook
*/
public static function after_group_membership_added(after_group_membership_added $hook): void {
quiz_overrides_cache_manager::purge_for_group_members($hook->groupinstance->id, $hook->userids);
}
/**
* Hook callback to clear relevant cache entries when a user is removed from a group.
*
* @param after_group_membership_removed $hook
*/
public static function after_group_membership_removed(after_group_membership_removed $hook): void {
quiz_overrides_cache_manager::purge_for_group_members($hook->groupinstance->id, $hook->userids);
}
}
@@ -0,0 +1,171 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
namespace mod_quiz\local;
use cache;
/**
* Manages cache operations for quiz overrides.
*
* Please do not use this class directly. Instead, use methods from \mod_quiz\local\override_manager.
*
* @package mod_quiz
* @copyright 2025 Catalyst IT Australia Pty Ltd
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_overrides_cache_manager {
/**
* Get all overrides for a given quiz and user from cache (or underlying data source).
*
* @param int $quizid The quiz id.
* @param int $userid The user id.
* @return array Array of overrides (empty when none).
*/
public static function get_overrides(int $quizid, int $userid): array {
$cache = self::get_cache();
$key = self::make_key($quizid, $userid);
$value = $cache->get($key);
return is_array($value) ? $value : [];
}
/**
* Purge all overrides from the overrides cache.
*/
public static function purge_all(): void {
self::get_cache()->purge();
}
/**
* Purge overrides for a specific user in a specific quiz.
*
* @param int $quizid The quiz id.
* @param int $userid The user id.
*/
public static function purge_for_user(int $quizid, int $userid): void {
self::purge_for_users($quizid, [$userid]);
}
/**
* Purge overrides for specific users in a specific quiz.
*
* @param int $quizid The quiz id.
* @param int[] $userids The user ids.
*/
public static function purge_for_users(int $quizid, array $userids): void {
if (empty($userids)) {
return;
}
$keys = array_map(static fn(int $userid): string => self::make_key($quizid, $userid), $userids);
self::get_cache()->delete_many($keys);
}
/**
* Purge overrides for all members of a given group in a specific quiz.
*
* @param int $quizid The quiz id.
* @param int $groupid The group id.
*/
public static function purge_for_group(int $quizid, int $groupid): void {
self::purge_for_groups($quizid, [$groupid]);
}
/**
* Purge overrides for all members of the given groups in a specific quiz.
*
* @param int $quizid The quiz id.
* @param array $groupids The group ids.
*/
public static function purge_for_groups(int $quizid, array $groupids): void {
global $DB;
[$insql, $params] = $DB->get_in_or_equal($groupids);
$sql = "SELECT DISTINCT userid
FROM {groups_members}
WHERE groupid {$insql}";
$userids = $DB->get_fieldset_sql($sql, $params);
if (!empty($userids)) {
self::purge_for_users($quizid, $userids);
}
}
/**
* Purge overrides for all members of a given group across all quizzes.
*
* @param int $groupid The group id.
* @param int[] $userids The user ids.
*/
public static function purge_for_group_members(int $groupid, array $userids): void {
global $DB;
if (empty($userids)) {
return;
}
$sql = "SELECT DISTINCT quiz
FROM {quiz_overrides}
WHERE groupid = :groupid";
$quizids = $DB->get_fieldset_sql($sql, ['groupid' => $groupid]);
foreach ($quizids as $quizid) {
self::purge_for_users((int) $quizid, $userids);
}
}
/**
* Purge overrides for all users and groups found in the given override records.
*
* @param array $overrides Records containing at least quiz id (quiz) and either a user id (userid) or group id (groupid).
*/
public static function purge_for_overrides(array $overrides): void {
$useridsbyquiz = [];
$groupidsbyquiz = [];
foreach ($overrides as $override) {
if (!empty($override->userid)) {
$useridsbyquiz[$override->quiz][] = (int) $override->userid;
}
if (!empty($override->groupid)) {
$groupidsbyquiz[$override->quiz][] = (int) $override->groupid;
}
}
foreach ($useridsbyquiz as $quizid => $userids) {
self::purge_for_users($quizid, array_unique($userids));
}
foreach ($groupidsbyquiz as $quizid => $groupids) {
self::purge_for_groups($quizid, array_unique($groupids));
}
}
/**
* Build the cache key.
*
* @param int $quizid The quiz id.
* @param int $userid The user id.
* @return string The cache key.
*/
private static function make_key(int $quizid, int $userid): string {
return "{$quizid}_{$userid}";
}
/**
* Get the overrides cache instance.
*/
private static function get_cache(): cache {
return cache::make('mod_quiz', 'quiz_overrides');
}
}
+4 -6
View File
@@ -27,12 +27,10 @@ declare(strict_types=1);
defined('MOODLE_INTERNAL') || die();
$definitions = [
'overrides' => [
'mode' => cache_store::MODE_APPLICATION,
// Overrides cache keyed by quizid_userid returning all applicable overrides.
'quiz_overrides' => [
'mode' => core_cache\store::MODE_APPLICATION,
'simplekeys' => true,
'datasource' => '\mod_quiz\cache\overrides',
'invalidationevents' => [
\mod_quiz\local\override_cache::INVALIDATION_USERDATARESET,
],
'datasource' => \mod_quiz\cache\quiz_overrides_cache::class,
],
];
+36
View File
@@ -0,0 +1,36 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* Hook callbacks for quiz module.
*
* @package mod_quiz
* @copyright 2025 Catalyst IT Australia Pty Ltd
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$callbacks = [
[
'hook' => \core_group\hook\after_group_membership_added::class,
'callback' => [\mod_quiz\local\override_manager::class, 'after_group_membership_added'],
],
[
'hook' => \core_group\hook\after_group_membership_removed::class,
'callback' => [\mod_quiz\local\override_manager::class, 'after_group_membership_removed'],
],
];
+1
View File
@@ -7,3 +7,4 @@ randomcoursewithsubcat,mod_quiz
randomsystemwithsubcat,mod_quiz
selectquestionbank,mod_quiz
gobacktoquiz,mod_quiz
cachedef_overrides,mod_quiz
+2 -1
View File
@@ -135,7 +135,7 @@ $string['browsersecurity_help'] = 'If "Full screen pop-up with some JavaScript s
* The quiz will only start if the student has a JavaScript-enabled web-browser
* The quiz appears in a full screen popup window that covers all the other windows and has no navigation controls
* Students are prevented, as far as is possible, from using facilities like copy and paste';
$string['cachedef_overrides'] = 'User and group override information';
$string['cachedef_quiz_overrides'] = 'User and group override information';
$string['calculated'] = 'Calculated';
$string['calculatedquestion'] = 'Calculated question not supported at line {$a}. The question will be ignored';
$string['cannotcreatepath'] = 'Path cannot be created ({$a})';
@@ -1169,3 +1169,4 @@ $string['randomsystemwithsubcat'] = 'Any system-level category';
// Deprecated since Moodle 5.2.
$string['gobacktoquiz'] = 'Go back';
$string['selectquestionbank'] = 'Select question bank';
$string['cachedef_overrides'] = 'User and group override information';
+21 -56
View File
@@ -26,6 +26,8 @@
*/
use core_question\local\bank\question_bank_helper;
use mod_quiz\local\override_manager;
use mod_quiz\local\quiz_overrides_cache_manager;
use qbank_managecategories\helper;
defined('MOODLE_INTERNAL') || die();
@@ -38,7 +40,6 @@ use mod_quiz\question\display_options;
use mod_quiz\question\qubaids_for_quiz;
use mod_quiz\question\qubaids_for_users_attempts;
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
use mod_quiz\local\override_cache;
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
@@ -1499,31 +1500,39 @@ function quiz_reset_userdata($data) {
'error' => false];
}
$purgeoverrides = false;
$overrides = [];
// Remove user overrides.
if (!empty($data->reset_quiz_user_overrides)) {
$DB->delete_records_select('quiz_overrides',
'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND userid IS NOT NULL', [$data->courseid]);
$select = 'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND userid IS NOT NULL';
$params = [$data->courseid];
$overrides = array_merge($overrides, $DB->get_records_select('quiz_overrides', $select, $params));
$DB->delete_records_select('quiz_overrides', $select, $params);
$status[] = [
'component' => $componentstr,
'item' => get_string('useroverrides', 'quiz'),
'error' => false];
$purgeoverrides = true;
}
// Remove group overrides.
if (!empty($data->reset_quiz_group_overrides)) {
$DB->delete_records_select('quiz_overrides',
'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND groupid IS NOT NULL', [$data->courseid]);
$select = 'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND groupid IS NOT NULL';
$params = [$data->courseid];
$overrides = array_merge($overrides, $DB->get_records_select('quiz_overrides', $select, $params));
$DB->delete_records_select('quiz_overrides', $select, $params);
$status[] = [
'component' => $componentstr,
'item' => get_string('groupoverrides', 'quiz'),
'error' => false];
$purgeoverrides = true;
}
// Updating dates - shift may be negative too.
if ($data->timeshift) {
$select = 'quiz IN (SELECT id FROM {quiz} WHERE course = ?)';
$params = [$data->courseid];
$overrides = array_merge($overrides, $DB->get_records_select('quiz_overrides', $select, $params));
$DB->execute("UPDATE {quiz_overrides}
SET timeopen = timeopen + ?
WHERE quiz IN (SELECT id FROM {quiz} WHERE course = ?)
@@ -1533,8 +1542,6 @@ function quiz_reset_userdata($data) {
WHERE quiz IN (SELECT id FROM {quiz} WHERE course = ?)
AND timeclose <> 0", [$data->timeshift, $data->courseid]);
$purgeoverrides = true;
// Any changes to the list of dates that needs to be rolled should be same during course restore and course reset.
// See MDL-9367.
shift_course_mod_dates('quiz', ['timeopen', 'timeclose'],
@@ -1546,8 +1553,8 @@ function quiz_reset_userdata($data) {
'error' => false];
}
if ($purgeoverrides) {
\cache_helper::purge_by_event(\mod_quiz\local\override_cache::INVALIDATION_USERDATARESET);
if (!empty($overrides)) {
quiz_overrides_cache_manager::purge_for_overrides($overrides);
}
return $status;
@@ -2212,50 +2219,8 @@ function quiz_get_coursemodule_info($coursemodule) {
*/
function mod_quiz_cm_info_dynamic(cm_info $cm) {
global $USER;
$cache = new override_cache($cm->instance);
$override = $cache->get_cached_user_override($USER->id);
if (!$override) {
$override = (object) [
'timeopen' => null,
'timeclose' => null,
];
}
// No need to look for group overrides if there are user overrides for both timeopen and timeclose.
if (is_null($override->timeopen) || is_null($override->timeclose)) {
$opens = [];
$closes = [];
$groupings = groups_get_user_groups($cm->course, $USER->id);
foreach ($groupings[0] as $groupid) {
$groupoverride = $cache->get_cached_group_override($groupid);
if (isset($groupoverride->timeopen)) {
$opens[] = $groupoverride->timeopen;
}
if (isset($groupoverride->timeclose)) {
$closes[] = $groupoverride->timeclose;
}
}
// If there is a user override for a setting, ignore the group override.
if (is_null($override->timeopen) && count($opens)) {
$override->timeopen = min($opens);
}
if (is_null($override->timeclose) && count($closes)) {
if (in_array(0, $closes)) {
$override->timeclose = 0;
} else {
$override->timeclose = max($closes);
}
}
}
// Populate some other values that can be used in calendar or on dashboard.
if (!is_null($override->timeopen)) {
$cm->override_customdata('timeopen', $override->timeopen);
}
if (!is_null($override->timeclose)) {
$cm->override_customdata('timeclose', $override->timeclose);
foreach (override_manager::get_effective_open_close_times($cm->instance, $USER->id) as $key => $value) {
$cm->override_customdata($key, $value);
}
}
@@ -1,80 +0,0 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_quiz\local;
/**
* Cache manager tests for quiz overrides
*
* @package mod_quiz
* @copyright 2024 Matthew Hilton <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \mod_quiz\local\override_cache
*/
final class override_cache_test extends \advanced_testcase {
/**
* Tests CRUD functions of the override_cache
*/
public function test_crud(): void {
// Cache is normally protected, but for testing we reflect it and put test data into it.
$overridecache = new override_cache(0);
$reflection = new \ReflectionClass($overridecache);
$getcache = $reflection->getMethod('get_cache');
$cache = $getcache->invoke($overridecache);
$getuserkey = $reflection->getMethod('get_user_cache_key');
$getgroupkey = $reflection->getMethod('get_group_cache_key');
$dummydata = (object)[
'userid' => 1234,
];
// Set some data.
$cache->set($getuserkey->invoke($overridecache, 123), $dummydata);
$cache->set($getgroupkey->invoke($overridecache, 456), $dummydata);
// Get the data back.
$this->assertEquals($dummydata, $overridecache->get_cached_user_override(123));
$this->assertEquals($dummydata, $overridecache->get_cached_group_override(456));
// Delete.
$overridecache->clear_for_user(123);
$overridecache->clear_for_group(456);
$this->assertEmpty($overridecache->get_cached_user_override(123));
$this->assertEmpty($overridecache->get_cached_group_override(456));
// Put some data back.
$cache->set($getuserkey->invoke($overridecache, 123), $dummydata);
$cache->set($getgroupkey->invoke($overridecache, 456), $dummydata);
// Clear it.
$overridecache->clear_for(123, 456);
$this->assertEmpty($overridecache->get_cached_user_override(123));
$this->assertEmpty($overridecache->get_cached_group_override(456));
// Put some data back.
$cache->set($getuserkey->invoke($overridecache, 123), 'testuser');
$cache->set($getgroupkey->invoke($overridecache, 456), 'testgroup');
// Purge it.
\cache_helper::purge_by_event(override_cache::INVALIDATION_USERDATARESET);
$this->assertEmpty($overridecache->get_cached_user_override(123));
$this->assertEmpty($overridecache->get_cached_group_override(456));
}
}
@@ -955,14 +955,14 @@ final class override_manager_test extends \advanced_testcase {
$this->assertCount(1, calendar_get_events(0, 999, [$user->id], false, false));
// Check that the cache was made.
$overridecache = new override_cache($quizobj->get_quizid());
$this->assertNotEmpty($overridecache->get_cached_user_override($user->id));
$this->assertNotEmpty(quiz_overrides_cache_manager::get_overrides($quizobj->get_quizid(), $user->id));
// Capture events.
$sink = $this->redirectEvents();
$override = (object) [
'id' => $id,
'quiz' => $quizobj->get_quizid(),
'userid' => $user->id,
];
@@ -973,7 +973,7 @@ final class override_manager_test extends \advanced_testcase {
$this->assertCount(0, calendar_get_events(0, 999, [$user->id], false, false));
// Check that the cache was cleared.
$this->assertEmpty($overridecache->get_cached_user_override($user->id));
$this->assertEmpty(quiz_overrides_cache_manager::get_overrides($quizobj->get_quizid(), $user->id));
// Check the event was logged.
if ($checkeventslogged) {
@@ -0,0 +1,252 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
namespace mod_quiz\local;
use advanced_testcase;
use context_module;
use stdClass;
/**
* Tests for the quiz overrides cache manager.
*
* @package mod_quiz
* @copyright 2025 Catalyst IT Australia Pty Ltd
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \mod_quiz\cache\quiz_overrides_cache
* @covers \mod_quiz\local\quiz_overrides_cache_manager
*/
final class quiz_overrides_cache_manager_test extends advanced_testcase {
/**
* Builds and returns a reusable quiz overrides testing context.
*
* @return stdClass
*/
private function create_test_data(): stdClass {
$this->resetAfterTest();
$this->setAdminUser();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
$quiz = $generator->create_module('quiz', ['course' => $course->id]);
$user1 = $generator->create_and_enrol($course);
$user2 = $generator->create_and_enrol($course);
$group = $generator->create_group(['courseid' => $course->id]);
groups_add_member($group->id, $user2->id);
$manager = new override_manager($quiz, context_module::instance($quiz->cmid));
return (object) [
'quiz' => $quiz,
'manager' => $manager,
'user1' => $user1,
'user2' => $user2,
'group' => $group,
];
}
/**
* Ensures getting overrides returns an empty array when none have been created.
*/
public function test_get_overrides_is_empty_initially(): void {
$data = $this->create_test_data();
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
}
/**
* Ensures a user override is returned only for the specified user.
*/
public function test_get_overrides_returns_user_override_for_correct_user(): void {
$data = $this->create_test_data();
$overrideid = $data->manager->save_override([
'userid' => $data->user1->id,
'timelimit' => HOURSECS,
]);
$overridesforuser1 = quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id);
$this->assertCount(1, $overridesforuser1);
$this->assertEquals($overrideid, reset($overridesforuser1)->id);
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
}
/**
* Ensures a group override is returned only for users who are members of the group.
*/
public function test_get_overrides_returns_group_override_for_group_member(): void {
$data = $this->create_test_data();
$overrideid = $data->manager->save_override([
'groupid' => $data->group->id,
'timelimit' => HOURSECS * 2,
]);
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$overridesforuser2 = quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id);
$this->assertCount(1, $overridesforuser2);
$this->assertEquals($overrideid, reset($overridesforuser2)->id);
}
/**
* Ensures that deleting an override by its ID invalidates the cache for the affected user.
*/
public function test_deleting_override_by_id_invalidates_cache(): void {
$data = $this->create_test_data();
$useroverrideid = $data->manager->save_override([
'userid' => $data->user1->id,
'timelimit' => HOURSECS,
]);
$data->manager->save_override([
'groupid' => $data->group->id,
'timelimit' => HOURSECS * 2,
]);
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
$data->manager->delete_overrides_by_id([$useroverrideid], false);
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
}
/**
* Ensures that deleting an override by its record invalidates the cache for affected users.
*/
public function test_deleting_override_record_invalidates_cache(): void {
global $DB;
$data = $this->create_test_data();
$data->manager->save_override([
'userid' => $data->user1->id,
'timelimit' => HOURSECS,
]);
$groupoverrideid = $data->manager->save_override([
'groupid' => $data->group->id,
'timelimit' => HOURSECS * 2,
]);
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
$groupoverride = $DB->get_record('quiz_overrides', ['id' => $groupoverrideid], '*', MUST_EXIST);
$data->manager->delete_overrides([$groupoverride], false);
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
}
/**
* Ensures that deleting all overrides for a quiz invalidates the cache for all users.
*/
public function test_deleting_all_overrides_invalidates_cache_for_all_users(): void {
$data = $this->create_test_data();
$data->manager->save_override([
'userid' => $data->user1->id,
'timelimit' => HOURSECS,
]);
$data->manager->save_override([
'groupid' => $data->group->id,
'timelimit' => HOURSECS * 2,
]);
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
$data->manager->delete_all_overrides(false);
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
}
/**
* Ensures that changes to group membership invalidate the relevant user caches.
*/
public function test_group_membership_changes_invalidate_cache(): void {
$data = $this->create_test_data();
$data->manager->save_override([
'userid' => $data->user1->id,
'timelimit' => HOURSECS,
]);
$data->manager->save_override([
'groupid' => $data->group->id,
'timelimit' => HOURSECS * 2,
]);
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
groups_add_member($data->group->id, $data->user1->id);
$this->assertCount(2, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
groups_remove_member($data->group->id, $data->user1->id);
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
groups_delete_group($data->group->id);
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
}
/**
* Ensures that purging the cache for an override invalidates the cache for all affected users.
*/
public function test_purge_for_override(): void {
global $DB;
$data = $this->create_test_data();
$useroverrideid = $data->manager->save_override([
'userid' => $data->user1->id,
'timelimit' => HOURSECS,
]);
$data->manager->save_override([
'groupid' => $data->group->id,
'timelimit' => HOURSECS * 2,
]);
$records = $DB->get_records('quiz_overrides');
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
$DB->delete_records('quiz_overrides', ['quiz' => $data->quiz->id]);
// Cached data still exists until the manager purges the relevant entries.
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
// Purge for the user override only.
quiz_overrides_cache_manager::purge_for_overrides([$records[$useroverrideid]]);
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertCount(1, quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
// Purge for all overrides.
quiz_overrides_cache_manager::purge_for_overrides($records);
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user1->id));
$this->assertSame([], quiz_overrides_cache_manager::get_overrides($data->quiz->id, $data->user2->id));
}
}