Diff moodle:sync cohort membership instead of clearing and re-inserting every run - #114
Merged
Conversation
…ng every run
moodle:sync was clearing and re-inserting every matched user's cohort
memberships on every run (~28k writes/pass), regardless of whether anything
had changed since the last run three hours earlier. Each cohort write triggers
Moodle's enrol_cohort sync, which bumps mdl_course.cacherev. The rating cohorts
(OBS, S1-C1, ZZN, ...) all enrol into one course ("Trainee Orientation",
id 8), so essentially every cohort insert funnels a cacherev UPDATE onto that
single row. Those UPDATEs serialize on the row lock: observed live with 16
concurrent `UPDATE mdl_course ... WHERE id=8` stuck 20-110s and 15 transactions
waiting on row locks, with sync throughput collapsing ~28x over a run (20,675
cohort writes in the first hour down to 728 in the fourth). Batching (#112/#113)
and a pacing sleep cut the HTTP call count but not the per-write cacherev storm,
so runs still degraded and overflowed the withoutOverlapping(120) window.
Organic (non-sync) cohort changes run at single digits per hour, so ~99.9% of
that churn was rewriting identical data. This diffs each user's desired cohorts
against their current membership (read in bulk per chunk via one query against
the moodle DB) and only issues the actual adds/removes. The end state is
identical to clear-then-reassign — the user ends up in exactly the desired
cohorts and no others — but a steady-state run writes almost nothing, so the
cacherev row-lock pileup goes away at the source. Indexing on the affected
tables is stock-Moodle-correct; the cacherev UPDATE is a primary-key point
update, so there was nothing to fix at the index layer.
Also stop retrying a failed sub-batch. A write that timed out on our side
usually keeps running server-side holding locks; a retry just adds a second
writer to the same contended rows and makes the pileup worse. moodle:sync
recomputes full desired state every run, so a skipped sub-batch self-heals on
the next pass.
Roles are still cleared-and-reassigned per user for now (a follow-up can apply
the same diff there); this change targets the cohort path, which is the dominant
source of the cacherev contention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Celeo
added a commit
that referenced
this pull request
Jul 22, 2026
… every run (#115) Follow-up to #114, which diffed cohort membership. Roles were still cleared and fully reassigned for every matched user every run — ~27,000 role writes per full pass (measured: one full run wrote 26,997 roles vs. 3 net cohort changes), which became the dominant recurring write load and the source of the residual sub-batch timeouts on the dense chunks (each ~1,000-role chunk pushing the single-core mod_php Moodle droplet past the 30s client timeout). This applies the same diff approach to roles. A role assignment's identity is (roleid, contextid); computeSyncItems returns desired roles (short names + context ids) and the caller diffs them against the user's current sync-managed assignments, read in bulk per chunk via VATUSAMoodle::getManagedRoleAssignmentsForUsers(). That query's filter mirrors clearUserRoles()'s default branch exactly — coursecat roles plus non-STU roles on exam courses under the VATUSA/exams context path, excluding enrolment-derived components — so the set of assignments the sync owns (and will remove) is identical to what clear-then-reassign deleted. Only genuine deltas are written: assignRolesBulk for missing roles, the new unassignRolesBulk (via core_role_unassign_roles) for stale ones. Steady state writes ~zero roles. End state is identical to clear-then-reassign: the user ends up with exactly the desired managed roles and no others. Roles with an unresolved role id or a null context id are skipped (they were no-op/erroring assignments before too). clearUserRoles()/clearUserCohorts() are now unused by the sync but retained on VATUSAMoodle as public methods (the $contexts/$isMtr branch of clearUserRoles and other helpers remain part of the class's interface). Also flushes role removes before role adds per chunk, matching the cohort ordering. The pacing sleep from #114 is kept: large *legitimate* add bursts (first-run backfill, new-ARTCC onboarding, post-outage catch-up) still fan cacherev bumps onto the shared hot course row, so pacing remains useful insurance even with writes diffed down. Testing: tests/Unit/MoodleRoleDiffTest.php covers the diff semantics (steady-state no-op, add missing, remove stale, add+remove, skip unknown role id, skip null context, dedupe duplicate desired). php -l clean on both files; full suite green (18 tests, 34 assertions). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
4 tasks
Celeo
added a commit
that referenced
this pull request
Jul 27, 2026
The bulk pass scans the entire users table every 3 hours with no activity filter, driving Moodle's cohort/role writes hard enough to trigger cacherev lock contention (see PRs #114/#115's diff-based rewrite, which cut write volume but didn't bound the scan itself). Users who haven't logged in in months have no pending facility/rating/ staff-role changes to reconcile, so there's no reason to keep including them as the table keeps growing. New-controller Observer cohort onboarding no longer depends on this job at all -- that's now handled at login time in cobalt (see companion cobalt PR) -- so narrowing this job's scope doesn't affect onboarding latency, only reduces backstop maintenance-sync volume. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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.
Problem
moodle:syncclears and re-inserts every matched user's cohort memberships on every run (~28k writes/pass), whether or not anything changed since the previous run three hours earlier. Each cohort write triggers Moodle'senrol_cohortsync, which bumpsmdl_course.cacherev. The rating cohorts (OBS, S1–C1, ZZN, …) all enrol into a single course — "Trainee Orientation",mdl_course.id = 8— so essentially every cohort insert funnels acacherevUPDATE onto that one row.Those UPDATEs serialize on the row lock. Observed live at the managed Moodle DB during an active run:
UPDATE mdl_course SET cacherev=… WHERE id='8'stuck 20–110s inupdatingstateInnodb_row_lock_current_waits = 15(purge lag was fine — history length 43, so this is pure lock queuing)Prior rounds (#112/#113 batching, plus a pacing sleep) cut the HTTP call count but not the per-write
cacherevstorm, so runs kept degrading and overflowing thewithoutOverlapping(120)window.Meanwhile, organic (non-sync) cohort changes run at single digits per hour — so roughly 99.9% of that churn was rewriting identical data.
Change
Diff each user's desired cohort set against their current membership and write only the actual adds/removes:
VATUSAMoodle::getCohortMembershipsForUsers()reads current memberships for a whole chunk of users in one query against themoodleDB (indexed onuserid).VATUSAMoodle::getCohortIdMap()memoizes the cohortidnumber → idmap (one query, 269 rows).VATUSAMoodle::removeCohortsBulk()removes stale memberships via the Web Service (not a direct DB delete) soenrol_cohortunenrols the user from linked courses properly.MoodleSync::diffCohorts()computes the delta;computeSyncItems()no longer callsclearUserCohorts()and now returns desired cohort idnumbers for the caller to diff.The end state is identical to the old clear-then-reassign — the user ends up in exactly the desired cohorts and no others — but a steady-state run writes almost nothing, so the
cacherevrow-lock pileup disappears at the source.Indexing on the affected tables is stock-Moodle-correct, and the
cacherevUPDATE is a primary-key point update, so there was nothing to fix at the index layer — the only lever is not generating the redundant writes.Also: stop retrying failed sub-batches
A write that timed out on our side usually keeps running server-side holding locks; retrying just adds a second writer to the same contended rows and makes the pileup worse. Since
moodle:syncrecomputes full desired state every run, a skipped sub-batch self-heals on the next pass, so a failed sub-batch is now logged and skipped rather than retried.Scope
Roles are still cleared-and-reassigned per user for now — a follow-up can apply the same diff to the role path. This PR targets the cohort path, which is the dominant source of the
cacherevcontention.Testing
tests/Unit/MoodleCohortDiffTest.phpcovers the diff semantics: steady-state emits no writes, missing memberships are added, stale ones removed, unknown desired idnumbers are skipped (never created), and duplicate desired idnumbers dedupe.php -lclean on both changed files; full suite green (11 tests, 20 assertions).information_schema.processlist/Innodb_row_lock_current_waitsand per-hourmdl_cohort_memberswrite counts. Success = a steady-state run writes tens (not tens of thousands) and finishes well inside the lock window with no stuckcacherevupdates.🤖 Generated with Claude Code