Skip to content

Commit 4e0da81

Browse files
committed
MDL-85517 course: Reset large courses asynchronously
Very large courses can take a long time to reset due to large number of users to unenrol, large numbers of grades to delete, etc. This moves the processing of a course reset to an ad-hoc task for courses over a certain threshold, currently based on the number of enrolments. A task indicator is displayed on the Reset page to show progress of the task, and the user is redirected back to the course page once the task is complete.
1 parent 237dfaa commit 4e0da81

File tree

9 files changed

+483
-46
lines changed

9 files changed

+483
-46
lines changed

admin/settings/subsystems.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,10 @@
9090
new lang_string('configallowemojipickerincompatible', 'admin')
9191
));
9292
}
93+
$optionalsubsystems->add(new admin_setting_configcheckbox(
94+
'enableasyncresets',
95+
new lang_string('enableasyncresets', 'admin'),
96+
new lang_string('configenableasyncresets', 'admin'),
97+
0
98+
));
9399
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
namespace core_course\exception;
18+
19+
use core\exception\moodle_exception;
20+
21+
/**
22+
* Exception thrown when a course reset takes too long
23+
*
24+
* @package core_course
25+
* @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}
26+
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
27+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */
28+
class reset_timeout extends moodle_exception {
29+
/**
30+
* Set the timeout message including details of the course.
31+
*
32+
* @param string $shortname
33+
*/
34+
public function __construct(string $shortname) {
35+
parent::__construct('resettimeout', 'course', a: $shortname);
36+
}
37+
38+
/**
39+
* Throw an exception if the timeout has been exceeded.
40+
*
41+
* This uses the provided $progress object to check whether the progress of the task is being tracked. If not, and the provided
42+
* time limit has passed, then we throw an exception.
43+
*
44+
* @param int $courseid The course ID.
45+
* @param ?int $timelimit The unix timestamp of the time limit to check. null means no limit.
46+
*/
47+
public static function check_reset_timeout(int $courseid, ?int $timelimit): void {
48+
global $DB;
49+
if (!is_null($timelimit) && time() > $timelimit) {
50+
$shortname = $DB->get_field('course', 'shortname', ['id' => $courseid]);
51+
throw new reset_timeout($shortname);
52+
}
53+
}
54+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
namespace core_course\task;
18+
19+
use core\task\adhoc_task;
20+
21+
/**
22+
* Asynchronously reset a course
23+
*
24+
* @package core_course
25+
* @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}
26+
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
27+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */
28+
class reset_course extends adhoc_task {
29+
use \core\task\logging_trait;
30+
use \core\task\stored_progress_task_trait;
31+
32+
/**
33+
* @var int The course weight above which an ad-hoc reset will be used.
34+
*/
35+
const ADHOC_THRESHOLD = 1000;
36+
37+
/**
38+
* Create and return an instance of this task for a given course ID.
39+
*
40+
* @param \stdClass $data
41+
* @return self
42+
*/
43+
public static function create(\stdClass $data): self {
44+
$task = new reset_course();
45+
$task->set_custom_data($data);
46+
$task->set_component('core_course');
47+
return $task;
48+
}
49+
50+
/**
51+
* Get the customdata for an instance of this task for the given course ID.
52+
*
53+
* We save all the options selected on the reset form in the task's custom data, which makes it difficult to reconstruct
54+
* for the purposes of displaying a task indicator. As we shouldn't be resetting the same course multiple times at once,
55+
* this lets us search for an instance of the task containing the course ID in the customdata, then returns the whole field
56+
* to be passed to {@see self::create()}.
57+
*
58+
* @param int $courseid
59+
* @return \stdClass|null
60+
* @throws \dml_exception
61+
*/
62+
public static function get_data_by_courseid(int $courseid): ?\stdClass {
63+
global $DB;
64+
$where = 'classname = ? AND ' . $DB->sql_like('customdata', '?');
65+
$params = [
66+
'\\' . self::class,
67+
'%"id":' . $courseid . ',%',
68+
];
69+
$customdata = $DB->get_field_select('task_adhoc', 'customdata', $where, $params);
70+
return $customdata ? json_decode($customdata) : null;
71+
}
72+
73+
/**
74+
* Run reset_course_userdata for the provided course id.
75+
*
76+
* @return void
77+
* @throws \dml_exception
78+
*/
79+
public function execute(): void {
80+
$data = $this->get_custom_data();
81+
$this->start_stored_progress();
82+
$this->log_start("Resetting course ID {$data->id}");
83+
// Ensure the course exists.
84+
try {
85+
$course = get_course($data->id);
86+
} catch (\dml_missing_record_exception $e) {
87+
$this->log("Course with id {$data->id} not found. It may have been deleted. Skipping reset.");
88+
return;
89+
}
90+
$this->log("Found course {$course->shortname}. Starting reset.");
91+
$results = reset_course_userdata($data, $this->get_progress(), maxseconds: null);
92+
93+
// Work out the max length of each column for nicer formatting.
94+
$done = get_string('statusdone');
95+
$lengths = array_reduce(
96+
$results,
97+
function ($carry, $result) {
98+
foreach ($carry as $key => $length) {
99+
$carry[$key] = max(strlen($result[$key]), $length);
100+
}
101+
return $carry;
102+
},
103+
['component' => 0, 'item' => 0, 'error' => 0]
104+
);
105+
$lengths['error'] = max(strlen($done), $lengths['error']);
106+
107+
$this->log(
108+
str_pad(get_string('resetcomponent'), $lengths['component']) . ' | ' .
109+
str_pad(get_string('resettask'), $lengths['item']) . ' | ' .
110+
str_pad(get_string('resetstatus'), $lengths['error'])
111+
);
112+
foreach ($results as $result) {
113+
$this->log(
114+
str_pad($result['component'], $lengths['component']) . ' | ' .
115+
str_pad($result['item'], $lengths['item']) . ' | ' .
116+
str_pad($result['error'] ?: $done, $lengths['error'])
117+
);
118+
}
119+
$this->log_finish('Reset complete.');
120+
}
121+
}

course/reset.php

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -69,36 +69,57 @@
6969
$mform = new course_reset_form();
7070

7171
} else {
72+
$data->reset_start_date_old = $course->startdate;
73+
$data->reset_end_date_old = $course->enddate;
74+
// If enabled, move the reset process to an adhoc task.
75+
if (get_config('core', 'enableasyncresets')) {
76+
$task = \core_course\task\reset_course::create($data);
77+
$taskid = \core\task\manager::queue_adhoc_task($task, true);
78+
$task->set_id($taskid);
79+
$task->initialise_stored_progress();
80+
redirect($PAGE->url);
81+
}
82+
7283
echo $OUTPUT->header();
7384
\backup_helper::print_coursereuse_selector('reset');
85+
$transaction = $DB->start_delegated_transaction();
86+
try {
87+
$status = reset_course_userdata($data);
88+
$transaction->allow_commit();
89+
$data = [];
90+
foreach ($status as $item) {
91+
$line = [];
92+
$line[] = $item['component'];
93+
$line[] = $item['item'];
94+
if ($item['error'] === false) {
95+
$line[] = get_string('statusdone');
96+
} else {
97+
$line[] = html_writer::div(
98+
$OUTPUT->pix_icon('i/invalid', get_string('error')) . $item['error'],
99+
);
100+
}
101+
$data[] = $line;
102+
}
74103

75-
$data->reset_start_date_old = $course->startdate;
76-
$data->reset_end_date_old = $course->enddate;
77-
$status = reset_course_userdata($data);
78-
79-
$data = [];
80-
foreach ($status as $item) {
81-
$line = [];
82-
$line[] = $item['component'];
83-
$line[] = $item['item'];
84-
if ($item['error'] === false) {
85-
$line[] = get_string('statusdone');
86-
} else {
87-
$line[] = html_writer::div(
88-
$OUTPUT->pix_icon('i/invalid', get_string('error')) . $item['error'],
104+
$table = new html_table();
105+
$table->head = [get_string('resetcomponent'), get_string('resettask'), get_string('resetstatus')];
106+
$table->size = ['20%', '40%', '40%'];
107+
$table->align = ['left', 'left', 'left'];
108+
$table->width = '80%';
109+
$table->data = $data;
110+
echo html_writer::table($table);
111+
} catch (\core_course\exception\reset_timeout $e) {
112+
try {
113+
$transaction->rollback($e);
114+
} catch (\Exception $e) {
115+
// After we roll back, the original exception is re-thrown. There might be a different exception if it failed,
116+
// so display whichever message we get.
117+
echo $OUTPUT->render(
118+
new \core\output\notification($e->getMessage(), \core\output\notification::NOTIFY_ERROR, false),
89119
);
90120
}
91-
$data[] = $line;
92121
}
93122

94-
$table = new html_table();
95-
$table->head = [get_string('resetcomponent'), get_string('resettask'), get_string('resetstatus')];
96-
$table->size = ['20%', '40%', '40%'];
97-
$table->align = ['left', 'left', 'left'];
98-
$table->width = '80%';
99-
$table->data = $data;
100-
echo html_writer::table($table);
101-
102123
echo $OUTPUT->continue_button('view.php?id=' . $course->id); // Back to course page.
103124
echo $OUTPUT->footer();
104125
exit;
@@ -112,8 +133,21 @@
112133
echo $OUTPUT->header();
113134
\backup_helper::print_coursereuse_selector('reset');
114135

115-
echo $OUTPUT->box(get_string('resetinfo'));
116-
echo $OUTPUT->box(get_string('resetinfoselect'));
136+
$taskdata = \core_course\task\reset_course::get_data_by_courseid($course->id);
137+
// If there is already a reset task queued for this course, display the indicator instead of the form.
138+
if ($taskdata) {
139+
$resettask = \core_course\task\reset_course::create($taskdata);
140+
$indicator = new \core\output\task_indicator(
141+
$resettask,
142+
heading: get_string('resetcourse'),
143+
message: get_string('resetcoursetask', 'course'),
144+
redirecturl: new moodle_url('/course/view.php', ['id' => $course->id]),
145+
);
146+
echo $OUTPUT->render($indicator);
147+
} else {
148+
echo $OUTPUT->box(get_string('resetinfo'));
149+
echo $OUTPUT->box(get_string('resetinfoselect'));
150+
$mform->display();
151+
}
117152

118-
$mform->display();
119153
echo $OUTPUT->footer();

course/tests/behat/reset_course.feature

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,55 @@ Feature: Reset course
7373
And I should not see "Student 1"
7474
And I am on the "Test assignment name" "assign activity" page
7575
And I should see "0" in the "Submitted" "table_row"
76+
77+
@javascript
78+
Scenario: Reset large courses asynchronously
79+
Given "2000" "users" exist with the following data:
80+
| username | studenta[count] |
81+
| firstname | Student |
82+
| lastname | [count] |
83+
| email | studenta[count]@example.com |
84+
And "2000" "course enrolments" exist with the following data:
85+
| user | studenta[count] |
86+
| course | C1 |
87+
| role | student |
88+
And "2000" "mod_assign > submissions" exist with the following data:
89+
| assign | Test assignment name |
90+
| user | studenta[count] |
91+
| onlinetext | I'm the studenta[count] submission |
92+
And I am on the "Course 1" "reset" page logged in as "teacher1"
93+
And I click on "Reset course" "button"
94+
And I click on "Reset course" "button" in the "Reset course?" "dialogue"
95+
And I should see "The course C1 is too large"
96+
# Check the course has not been reset.
97+
And I navigate to course participants
98+
And I should see "Teacher 1"
99+
And I should see "Student 1"
100+
And I am on the "Test assignment name" "assign activity" page
101+
And I should see "2001" in the "Submitted" "table_row"
102+
# Enable asynchronous resets and try again.
103+
When the following config values are set as admin:
104+
| enableasyncresets | 1 |
105+
And I am on the "Course 1" "reset" page logged in as "teacher1"
106+
And I click on "Reset course" "button"
107+
And I click on "Reset course" "button" in the "Reset course?" "dialogue"
108+
# Check that you're on the Reset page with the indicator displayed
109+
Then I should see "Reset course"
110+
And I should not see "This feature allows you to clear all user data and reset the course to its original state"
111+
And I should see "This course is being reset. You do not need to stay on this page."
112+
And I reload the page
113+
And I should not see "This feature allows you to clear all user data and reset the course to its original state"
114+
And I should see "This course is being reset. You do not need to stay on this page."
115+
And I run all adhoc tasks
116+
And I wait until "Unenrol users" "text" exists
117+
And I wait until "Unenrol users" "text" does not exist
118+
And I wait until "Reset course" "text" does not exist
119+
# Check the course has been reset.
120+
And I navigate to course participants
121+
And I should see "Teacher 1"
122+
And I should not see "Student 1"
123+
And I am on the "Test assignment name" "assign activity" page
124+
And I should see "0" in the "Submitted" "table_row"
125+
And I am on the "Course 1" "reset" page logged in as "teacher1"
126+
And I should see "This feature allows you to clear all user data and reset the course to its original state"
127+
And I should not see "This course is being reset. You do not need to stay on this page."

0 commit comments

Comments
 (0)