Stop stranding activities, and clear the ones already stranded - #20
Merged
Conversation
course_delete_module() calls the module's delete_instance() first and removes the
course_modules row last. The manual fallback copied everything except that first
call, so a forced deletion left the activity's own row behind - unreachable from
the site, and still picked up by that module's cron, which then failed site-wide
on a record it could not resolve. mod_assign is where it shows: assign::cron()
selects straight from {assign} and resolves each row with MUST_EXIST.
Where the course is gone, core cannot run delete_instance() at all, so the row and
its calendar entries are removed directly. If the activity cannot be removed for
any other reason the fallback now aborts and leaves the course module in place: a
stuck module is recoverable, a stranded activity is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ugk6HWGJ5gcHdXZUnZ3NZX
An activity row with no course module is unreachable from the site but not from cron, and there is no way to remove it: every module's delete_instance() finds the activity through the course module that is missing, and core ships nothing that covers this - fix_course_sequence only reconciles sections against course modules and never opens a module table. cli/fix_orphaned_instances.php reports by default, and scopes down to a single module, course or instance so a production repair can touch one row. Where the course survives, the course module is put back hidden and marked for deletion and course_delete_module() does the work; where it does not, the row and its calendar entries go directly. It is repair, not maintenance: local_cleanup\repair is not a step, step_factory does not build it, and nothing here runs from cron. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ugk6HWGJ5gcHdXZUnZ3NZX
…ing it The repair tests assert both halves: what goes (the activity, its grade item, its calendar entries, the course module rebuilt to delete it) and what stays (an activity still in use, one inside the grace period, another module, another course, another instance). The two added to the stuck modules step cover the fallback itself, which nothing reached before: a forced deletion must take the activity with it, and must still finish when the activity has already gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ugk6HWGJ5gcHdXZUnZ3NZX
README gains a section opposite "Stuck course modules": the cron failure it shows up as, why it never clears itself, that core has nothing for it, and what the command does in each of the two cases - including the child rows deliberately left behind when the course is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ugk6HWGJ5gcHdXZUnZ3NZX
(int)'abc' is 0, and 0 meant "no restriction", so --instanceid=12x635 --execute deleted every stranded activity of that module instead of one, and a bare --courseid (no =) came out as course 1. --days=abc removed the grace period the whole command rests on, then printed a warning naming a value nobody typed. Anything that is not already a whole number is now rejected by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ugk6HWGJ5gcHdXZUnZ3NZX
Both loops closed on the success path only, so a failure part-way through left the cursor open for the rest of the run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ugk6HWGJ5gcHdXZUnZ3NZX
execute() had no ceiling, so a large backlog meant one process deleting every activity it found - the steps all honour maxrecordsperrun for the same reason. The report is deliberately not capped: its whole number is how an operator knows how many runs the backlog will take. The command was also dropping step_result notes on the floor, which is where the "reached the per-run limit" line comes out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ugk6HWGJ5gcHdXZUnZ3NZX
mod_survey installs five template rows - course 0, no course module, timemodified from 2001 - and every one of them matched. On a real site --execute would have deleted the site's survey templates. Caught by CI on 4.1 and 4.5; the 5.0 job passed only because core dropped mod_survey there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ugk6HWGJ5gcHdXZUnZ3NZX
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
A production site hit
mod_assign\task\cron_taskfailing every run with Can not find datarecord in database table on
assigninstance 126355. The activity row exists; nothing pointsat it.
assign::cron()selects straight from{assign}and resolves each row withMUST_EXIST, so one stranded row fails the task for the whole site — and keeps failing, becausea failed scheduled task has its
nextruntimeandfaildelaybumped whilelastruntimeis leftalone, so the window it searches only widens. Everything after that point in the task is skipped
too, which for assignments means the
assignsubmission_*andassignfeedback_*crons.We made it.
course_delete_module()calls the module's owndelete_instance()first andremoves the
course_modulesrow last; our manual fallback in the stuck modules step copiedeverything except that first call. So a forced deletion took the course module and left the
activity.
This fixes the fallback and adds a command to clear activities already stranded by it — by that
defect, or by anything else that half-deleted a module. Core has nothing for this:
fix_course_sequence.phponly reconcilescourse_sections.sequenceagainstcourse_modulesand never opens a module's own table.
How
clean_up_course_module_data()now calls<mod>_delete_instance()where core calls it. Wherethe activity cannot be removed, the fallback aborts and leaves the course module in place — a
stuck module is recoverable, a stranded activity is not.
cli/fix_orphaned_instances.phpreports unless given--execute, and narrows with--modules,--courseid,--instanceid,--limitand--days.--courseidworks for acourse that has already been deleted, because the activity row keeps the id of the course it
belonged to.
deletioninprogressfrom the moment it exists — andcourse_delete_module()does the work,so grades, files, calendar events, tags and the context all go through core. If that fails
part-way, what is left is a stuck course module, which the existing step already handles.
delete_instance()finds theactivity through the course module and the course), so the row and its calendar entries are
deleted directly. The module's own child rows are left behind — see Notes.
local_cleanup\repairis deliberately not a step:step_factorydoes not build it andnothing here runs from cron. Activities modified within
--days(default 7) are left alone,because having no course module yet is normal part-way through creating or restoring one.
Testing
The test suite was not run. It needs a full Moodle install and only runs in CI, so the 14
tests added here are unverified until this build goes green.
What was run locally, through the project's container:
php -lover the whole tree on 7.4 and 8.1 — 62 files, all parse. 7.4 matters: it is thedeclared floor, and an 8.x pass alone would not prove it.
$_GET/$_POST/$_REQUEST, no PHP 8-only functions.'abc', bare--courseid(true)and
'12.5'rejected;'1234',7and'0'accepted./gku:reviewran against this branch and found one blocker, two warnings and three nits; theblocker, both applicable warnings and two nits are fixed in the last three commits.
Acceptance criteria
<mod>_delete_instance()runs before the module context is deleted, because it needs it_delete_instanceis missing is reported and skipped, not half-deletedstep_factory::create_all()is unchangedand "course is gone", and writes nothing
--executeon an orphan whose course exists leaves no instance row, no course module, nograde item and no calendar event, and gets there through
course_delete_module()--executeon an orphan whose course is gone leaves no instance row and no calendar event--daysis left alone, in both report and execute--modules=assignopens only{assign}; an unknown or malformed name is rejected--courseid=Nrestricts both report and execute, and still finds them when that course rowhas been deleted
--instanceid=Nacts on exactly that one instance, and is rejected unless--modulesnamesexactly one module
courseortimemodifiedis reported as unsupported and skipped$plugin->versionis bumpedA new site setting for the grace period— dropped: it is--days, a command optionNotes
$plugin->versionbumped to2026083100for the new class underclasses/.child rows (
assign_submissionand the like) are left behind. They reference an activity thatno longer exists, which breaks nothing, and a per-module list of table names could never be
complete — assignments alone would need six, plus every
assignsubmission_*andassignfeedback_*subplugin. Grade items for a dead course are already covered by the gradesstep, and contexts by core's own clean-up task.
happened. It now aborts if the activity cannot be removed, so a module that previously
disappeared (leaving a stranded row) will now stay listed as stuck.
CHANGELOG.mdgained an## [Unreleased]heading while$plugin->releaseis still3.0.Drop the empty Unreleased heading from the changelog #18 dropped that heading when it was empty; this one is not. Say if you would rather cut a
release number instead.
one, and making it testable would mean extracting an options parser that exists nowhere else
in the repo. Happy to add the seam if you want it.