Skip to content

Stop stranding activities, and clear the ones already stranded - #20

Merged
matasarei merged 8 commits into
masterfrom
fix/orphaned-module-instances
Aug 30, 2026
Merged

Stop stranding activities, and clear the ones already stranded#20
matasarei merged 8 commits into
masterfrom
fix/orphaned-module-instances

Conversation

@matasarei

Copy link
Copy Markdown
Collaborator

What and why

A production site hit mod_assign\task\cron_task failing every run with Can not find data
record in database table
on assign instance 126355. The activity row exists; nothing points
at it. assign::cron() selects straight from {assign} and resolves each row with
MUST_EXIST, so one stranded row fails the task for the whole site — and keeps failing, because
a failed scheduled task has its nextruntime and faildelay bumped while lastruntime is left
alone, so the window it searches only widens. Everything after that point in the task is skipped
too, which for assignments means the assignsubmission_* and assignfeedback_* crons.

We made it. course_delete_module() calls the module's own delete_instance() first and
removes the course_modules row last; our manual fallback in the stuck modules step copied
everything 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.php only reconciles course_sections.sequence against course_modules
and never opens a module's own table.

How

  • clean_up_course_module_data() now calls <mod>_delete_instance() where core calls it. Where
    the 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.php reports unless given --execute, and narrows with
    --modules, --courseid, --instanceid, --limit and --days. --courseid works for a
    course that has already been deleted, because the activity row keeps the id of the course it
    belonged to.
  • Where the activity's course survives, the course module is rebuilt — hidden, and marked
    deletioninprogress from the moment it exists — and course_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.
  • Where the course is gone, core cannot be used at all (every delete_instance() finds the
    activity 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\repair is deliberately not a step: step_factory does not build it and
    nothing 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 -l over the whole tree on 7.4 and 8.1 — 62 files, all parse. 7.4 matters: it is the
    declared floor, and an 8.x pass alone would not prove it.
  • Both CI grep guards: no $_GET/$_POST/$_REQUEST, no PHP 8-only functions.
  • The argument guard, against the inputs that motivated it: 'abc', bare --courseid (true)
    and '12.5' rejected; '1234', 7 and '0' accepted.

/gku:review ran against this branch and found one blocker, two warnings and three nits; the
blocker, both applicable warnings and two nits are fixed in the last three commits.

Acceptance criteria

  • A forced deletion leaves no row in the module's own table
  • <mod>_delete_instance() runs before the module context is deleted, because it needs it
  • A module whose lib or _delete_instance is missing is reported and skipped, not half-deleted
  • Nothing new runs from cron — step_factory::create_all() is unchanged
  • The command with no arguments counts orphans per module, split into "course still exists"
    and "course is gone", and writes nothing
  • --execute on an orphan whose course exists leaves no instance row, no course module, no
    grade item and no calendar event, and gets there through course_delete_module()
  • --execute on an orphan whose course is gone leaves no instance row and no calendar event
  • An instance modified within --days is left alone, in both report and execute
  • An instance that has a course module is never a candidate
  • --modules=assign opens only {assign}; an unknown or malformed name is rejected
  • --courseid=N restricts both report and execute, and still finds them when that course row
    has been deleted
  • --instanceid=N acts on exactly that one instance, and is rejected unless --modules names
    exactly one module
  • The report lists the instance ids it found, with their course ids, not only totals
  • A module table lacking course or timemodified is reported as unsupported and skipped
  • $plugin->version is bumped
  • A new site setting for the grace period — dropped: it is --days, a command option

Notes

  • $plugin->version bumped to 2026083100 for the new class under classes/.
  • Known limitation, documented in the README: where the course is gone, the module's own
    child rows (assign_submission and the like) are left behind. They reference an activity that
    no 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_* and
    assignfeedback_* subplugin. Grade items for a dead course are already covered by the grades
    step, and contexts by core's own clean-up task.
  • Behaviour change worth knowing: the fallback used to delete the course module whatever
    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.md gained an ## [Unreleased] heading while $plugin->release is still 3.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.
  • Not done: the command's argument validation has no test. No CLI script in this plugin has
    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.

matasarei and others added 8 commits August 31, 2026 01:51
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
@matasarei
matasarei merged commit 6545d5a into master Aug 30, 2026
5 checks passed
@matasarei
matasarei deleted the fix/orphaned-module-instances branch August 30, 2026 23:36
@matasarei matasarei mentioned this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant