Skip to content

Diff moodle:sync cohort membership instead of clearing and re-inserting every run - #114

Merged
Celeo merged 1 commit into
masterfrom
moodle-sync-cohort-diff
Jul 22, 2026
Merged

Diff moodle:sync cohort membership instead of clearing and re-inserting every run#114
Celeo merged 1 commit into
masterfrom
moodle-sync-cohort-diff

Conversation

@Celeo

@Celeo Celeo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

moodle:sync clears 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's enrol_cohort sync, which bumps mdl_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 a cacherev UPDATE onto that one row.

Those UPDATEs serialize on the row lock. Observed live at the managed Moodle DB during an active run:

  • 16 concurrent UPDATE mdl_course SET cacherev=… WHERE id='8' stuck 20–110s in updating state
  • Innodb_row_lock_current_waits = 15 (purge lag was fine — history length 43, so this is pure lock queuing)
  • Throughput collapsing ~28× over a single run: 20,675 cohort writes in the first hour down to 728 in the fourth, as the lock queue on that one row grows

Prior rounds (#112/#113 batching, plus a pacing sleep) cut the HTTP call count but not the per-write cacherev storm, so runs kept degrading and overflowing the withoutOverlapping(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 the moodle DB (indexed on userid).
  • VATUSAMoodle::getCohortIdMap() memoizes the cohort idnumber → id map (one query, 269 rows).
  • VATUSAMoodle::removeCohortsBulk() removes stale memberships via the Web Service (not a direct DB delete) so enrol_cohort unenrols the user from linked courses properly.
  • MoodleSync::diffCohorts() computes the delta; computeSyncItems() no longer calls clearUserCohorts() 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 cacherev row-lock pileup disappears at the source.

Indexing on the affected tables is stock-Moodle-correct, and the cacherev UPDATE 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:sync recomputes 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 cacherev contention.

Testing

  • tests/Unit/MoodleCohortDiffTest.php covers 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 -l clean on both changed files; full suite green (11 tests, 20 assertions).
  • No isolated staging Moodle exists, so the live verification is the same as prior rounds: deploy, run a full pass, and watch information_schema.processlist / Innodb_row_lock_current_waits and per-hour mdl_cohort_members write counts. Success = a steady-state run writes tens (not tens of thousands) and finishes well inside the lock window with no stuck cacherev updates.

🤖 Generated with Claude Code

…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
Celeo merged commit 86c1a49 into master Jul 22, 2026
3 checks passed
@Celeo
Celeo deleted the moodle-sync-cohort-diff branch July 22, 2026 03:27
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>
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>
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