Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This has been tested to cover the following course_delete_module adhoc task fail
- course_module table record absent.
- module's table (e.g. quiz) record absent.
- context table record absent.
- **course module flagged with `deletioninprogress=1` but no matching adhoc task in the queue ("orphaned" course module).**

## Check ##
- If the task(s) contains multiple course modules, it will recommend
Expand All @@ -18,6 +19,13 @@ This has been tested to cover the following course_delete_module adhoc task fail
course modules. For example, the course module record or the assign table
record might be missing. See "Resolve" for information about how the plugin
can resolve these.
- If there are **orphaned course modules** — rows in `course_modules` flagged
with `deletioninprogress=1` but with no matching `course_delete_modules`
adhoc task in the queue — the plugin will list them in a separate "Orphaned
course modules" section. This can happen when a worker process is killed
mid-execute (for example by a worker time-out, OOM, or container restart)
before the framework can mark the task as failed, leaving the cm flagged
but no longer queued.

## Resolve ##
- Separate "clustered" adhoc tasks:
Expand All @@ -29,6 +37,12 @@ This has been tested to cover the following course_delete_module adhoc task fail
If a task is incomplete, the plugin can delete the remnant data records
and clear off the course_delete_adhoc task. Although this process will NOT
backup the module to the recycle bin.
- Re-queue orphaned course modules:
For each orphaned cm (and the bulk action for all of them), the plugin
re-queues a fresh `course_delete_modules` adhoc task via Moodle's standard
`course_delete_module($cmid, true)` helper. This is the same code path the
activity Delete button uses, so all normal deletion hooks, observers and
events fire as expected. The next cron run completes the deletion as normal.

## GUI and CLI ##
There is a GUI side and a CLI to the plugin.
Expand All @@ -44,6 +58,14 @@ Instructions of how to use the CLI can be found in the help page:

$ php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --help

The CLI also supports the orphan-cm scan via the `--requeue-orphans` flag:

$ # List orphaned course modules without making changes:
$ php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --requeue-orphans

$ # Re-queue them via course_delete_module() so cron will complete the deletion:
$ php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --requeue-orphans --fix

## Branches ##

| Moodle version | Branch |
Expand Down
112 changes: 112 additions & 0 deletions classes/check/orphan_modules.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?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/>.

/**
* Check API entry for orphaned course modules.
*
* @package tool_fix_delete_modules
* @category check
* @copyright 2026 Catalyst IT
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

namespace tool_fix_delete_modules\check;

use core\check\check;
use core\check\result;

/**
* Status check: reports orphaned course modules.
*
* An "orphaned" course module is a row in course_modules with deletioninprogress=1
* that has no matching \core_course\task\course_delete_modules adhoc task queued —
* typically left behind when a worker is killed mid-execute (time-out, OOM, container
* restart) before the framework can mark the task as failed. With no task left in the
* queue, cron will never finish the deletion and the activity stays "deletion in
* progress" on its course page indefinitely.
*
* Surfacing this on /report/status/index.php (core) and on the croncheck page
* provided by tool_heartbeat means external monitoring picks it up automatically.
*
* @package tool_fix_delete_modules
* @category check
* @copyright 2026 Catalyst IT
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class orphan_modules extends check {
/**
* Localised check name.
*
* @return string
*/
public function get_name(): string {
return get_string('check_orphan_modules_name', 'tool_fix_delete_modules');
}

/**
* Action link — drops the admin straight at the orphaned-modules section
* of the plugin's index page, where the re-queue buttons live.
*
* @return \action_link|null
*/
public function get_action_link(): ?\action_link {
$url = new \moodle_url('/admin/tool/fix_delete_modules/index.php', null, 'orphan-modules');
return new \action_link($url, get_string('pluginname', 'tool_fix_delete_modules'));
}

/**
* Run the check.
*
* Uses the same orphan_module_list scan the GUI/CLI flows use, so the result
* is always consistent with what the plugin's report page shows.
*
* @return result
*/
public function get_result(): result {
global $DB;

$list = new \tool_fix_delete_modules\orphan_module_list();
$orphans = $list->get_orphan_modules();
$count = count($orphans);

if ($count === 0) {
return new result(
result::OK,
get_string('check_orphan_modules_ok', 'tool_fix_delete_modules')
);
}

// The list is bounded (worst observed in production: 43) and the underlying
// scan is the same one the report page runs, so we don't need a separate
// lazy result class — just build the bullet list inline.
$rows = [];
foreach ($orphans as $cm) {
$modname = $DB->get_field('modules', 'name', ['id' => $cm->module]) ?: ('module#' . $cm->module);
$rows[] = \html_writer::tag(
'li',
"cmid {$cm->id} — course {$cm->course} — {$modname} (instance {$cm->instance})"
);
}
$details = \html_writer::tag('p', get_string('check_orphan_modules_details_intro', 'tool_fix_delete_modules'))
. \html_writer::tag('ul', implode('', $rows));

return new result(
result::WARNING,
get_string('check_orphan_modules_warning', 'tool_fix_delete_modules', $count),
$details
);
}
}
117 changes: 117 additions & 0 deletions classes/orphan_module_list.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?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/>.

/**
* Class to enumerate orphaned course modules: rows in course_modules with
* deletioninprogress = 1 that have no matching \core_course\task\course_delete_modules
* adhoc task queued.
*
* @package tool_fix_delete_modules
* @category admin
* @copyright 2026 Catalyst IT
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

namespace tool_fix_delete_modules;

/**
* List of orphaned course modules — flagged for deletion but no queued task.
*
* @package tool_fix_delete_modules
* @category admin
* @copyright 2026 Catalyst IT
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class orphan_module_list {
/** @var \stdClass[] $orphanmodules course_modules rows that are stuck without a queued task, keyed by cmid. */
private $orphanmodules;

/**
* Constructor — populates the orphan list immediately.
*/
public function __construct() {
$this->set_orphan_modules();
}

/**
* Get the array of orphaned course module records, keyed by cmid.
*
* @return \stdClass[]
*/
public function get_orphan_modules() {
return $this->orphanmodules;
}

/**
* Get just the cmids of orphaned modules.
*
* @return int[]
*/
public function get_cmids() {
return array_keys($this->orphanmodules);
}

/**
* Whether any orphaned modules exist.
*
* @return bool
*/
public function has_orphans() {
return !empty($this->orphanmodules);
}

/**
* Build the list:
* 1. Find every cm with deletioninprogress=1.
* 2. Walk every queued course_delete_modules adhoc task and collect referenced cmids.
* 3. Stuck cms not referenced by any task = orphans.
*
* Avoids JSON_EXTRACT for cross-DB portability (MySQL/PostgreSQL).
*/
private function set_orphan_modules() {
global $DB;

$this->orphanmodules = [];

// Step 1: stuck cms.
$stuck = $DB->get_records('course_modules', ['deletioninprogress' => 1], 'course, id');
if (empty($stuck)) {
return;
}

// Step 2: cmids referenced by any queued task.
$referenced = [];
$tasks = \core\task\manager::get_adhoc_tasks('\core_course\task\course_delete_modules');
foreach ($tasks as $task) {
$customdata = $task->get_custom_data();
if (empty($customdata) || empty($customdata->cms)) {
continue;
}
foreach ($customdata->cms as $cm) {
if (isset($cm->id)) {
$referenced[(int)$cm->id] = true;
}
}
}

// Step 3: subtract.
foreach ($stuck as $cm) {
if (!isset($referenced[(int)$cm->id])) {
$this->orphanmodules[(int)$cm->id] = $cm;
}
}
}
}
Loading