Batch moodle:sync Moodle writes across users to stop stacking - #113
Merged
Conversation
PR #112 removed the per-user Moodle existence-check HTTP storm, but live prod evidence showed runs still stack: the 25,152-user matched set still drives one core_user_update_users, one core_cohort_add_cohort_members, one core_role_assign_roles, and (for staff) one enrol_manual_enrol_users call per user, ~75-100k HTTP round-trips total. Scheduled runs since #112's deploy never completed (no "Finished scheduled task" log line across four consecutive 3-hour ticks), and a manual foreground run ran 36+ minutes without finishing before being backgrounded. This splits sync() into a pure compute step (computeSyncItems()) that returns per-user update/cohort/role/enrolment items instead of flushing them immediately, and has handle()'s chunk(1000) loop accumulate those across the whole chunk before flushing 4 bulk HTTP calls per chunk instead of per user (~104 calls total for the full table instead of ~75-100k). Also: - Add VATUSAMoodle::updateUsersBulk(), mirroring the existing assignCohortsBulk/assignRolesBulk/enrolUsersBulk bulk helpers - core_user_update_users was the one per-user HTTP call #112 left unbatched. - Memoize getCoursesInCategory() the same way getCategories() already is, since staff/instructor users were re-fetching the same category's course list by HTTP on every occurrence. - Add logger() calls around the bulk pass (matched-user count at start, per-chunk item counts, a final run summary, and a log-and-rethrow around each bulk call) so a run's progress and any mid-run failure are visible in logs instead of requiring a live kubectl/ps/DB investigation like tonight's. Single-user CLI path (moodle:sync {cid}) and all existing single-item VATUSAMoodle methods (getUserId, assignCohort, assignRole, enrolUser, createUser, updateUser) are unchanged - only new additive bulk/memoized methods were introduced. Kernel.php scheduling and withoutOverlapping(120) are untouched. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Celeo
added a commit
that referenced
this pull request
Jul 21, 2026
assignCohortsBulk(), assignRolesBulk(), and enrolUsersBulk() (added in #112) didn't pass self::METHOD_POST to request(), so they defaulted to METHOD_GET. VATUSAMoodle::request() builds GET requests by http_build_query()-ing the whole parameters array into the URL query string. That was invisible in #112 because each call only ever carried one user's handful of cohorts/roles. #113's cross-user chunk batching (up to ~1000 users per call) pushed the query string past the server's URL length limit: the first manual production run after #113 deployed failed immediately with "HTTP/1.1 414 URI Too Long" on chunk 1's cohort assignment call. Fix: pass self::METHOD_POST on all three, matching the pattern already used by createUser(), updateUser(), and updateUsersBulk() (the one #113 bulk helper that already had this right). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Celeo
added a commit
that referenced
this pull request
Jul 22, 2026
…ng every run (#114) 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>
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.
Summary
Follow-up to #112. That PR fixed the whole-table existence-check HTTP storm (
getUserId()called once per VATUSA user just to check "is this CID in Moodle?"), but live production evidence after deploying it showedmoodle:syncruns are still stacking — just more slowly than before. This PR batches the remaining per-user write calls across users, not just within a user, to get the whole run's HTTP call count down from tens of thousands to roughly one hundred.Why #112 wasn't enough
#112 replaced the existence-check loop with one query (
VATUSAMoodle::getAllUserIdMap()) and batched each matched user's own writes into per-user bulk calls. That fixed the dominant cost, but the write path insync()still ran once per matched user — and the matched set is large: a direct query against Moodle's DB confirmed 25,152 users currently match (deleted = 0, non-blankidnumber). For each of those, the old-#112 code still made:core_user_update_users(single-item — never batched by Fix moodle:sync stacking by cutting per-user Moodle HTTP call volume #112)core_cohort_add_cohort_members(batched within one user by Fix moodle:sync stacking by cutting per-user Moodle HTTP call volume #112, but still one HTTP call per user)core_role_assign_roles(same)enrol_manual_enrol_usersfor staff/instructor/mentor users, preceded bygetAllSubcategories/getCoursesInCategoryHTTP calls that weren't memoized, so the same category's course list could be re-fetched by HTTP on every staff user who touched itThat's still on the order of 75,000-100,000 HTTP round-trips for a full run. After deploying #112:
php artisan moodle:sync) ran 36+ minutes without finishing (backgrounded past the shell's timeout).Starting scheduled task: moodle:syncwith zero matchingFinishedlines.pson theapi-workerpod showed two live overlappingmoodle:syncprocesses (3h55m and 55m elapsed) before the older one was manually killed to stop the immediate bleeding.Runtime dropped a lot from the pre-#112 17+ hours, but still exceeds the
withoutOverlapping(120)2-hour lock window, so the same stacking mechanism recurs — just on a longer clock.What's changing
MoodleSync::sync()is split into a pure compute step,computeSyncItems(). It keeps every existing cohort/role/enrolment decision (rating cohorts, home facility, visiting facilities, STU/INS/TA/CBT/FACCBT/MTR branches — all unchanged) but returns the computed items as arrays instead of immediately calling the Moodle bulk methods. Per-userclearUserCohorts()/clearUserRoles()stay per-user since they're direct DB deletes, not HTTP, and were never the bottleneck.handle()'schunk(1000)loop now accumulates items across the whole chunk and flushes 4 bulk HTTP calls per chunk (update/cohorts/roles/enrolments) instead of ~4 per user. For 25,152 users at chunk size 1000, that's ~26 chunks × 4 calls ≈ ~104 HTTP calls total for a full run, down from ~75,000-100,000.VATUSAMoodle::updateUsersBulk(array $items), mirroring the existingassignCohortsBulk/assignRolesBulk/enrolUsersBulkpattern —core_user_update_userswas the one per-user HTTP call Fix moodle:sync stacking by cutting per-user Moodle HTTP call volume #112 left unbatched.getCoursesInCategory()is now memoized the same waygetCategories()already was, keyed by category id, so a staff user's category tree isn't re-fetched by HTTP if another user already pulled the same category's courses earlier in the run.logger()calls around the bulk pass: matched-user count at start, per-chunk item counts, a final run summary, and a log-and-rethrow around each of the 4 bulk calls per chunk. Right now a run is a black box until it finishes (or never does) — tonight's incident was only diagnosable viapsand a raw Moodle DB query. These lines mean the next run's actual progress is visible in application logs in real time, and a mid-run failure names the exact chunk and call that broke instead of an opaque Moodle error.moodle:sync {cid}) is preserved with the same create-or-update decision as before, just routed throughcomputeSyncItems()and flushed immediately as single-item bulk calls (functionally identical to the old immediate-flush behavior, aside from one intentionally-accepted redundantupdateUsersBulk()call aftercreateUser()for brand-new users — harmless sincecreateUser()already sets those same fields).Behavior preservation
No changes to
Kernel.php,withoutOverlapping(120), or the signature of any existing single-itemVATUSAMoodlemethod (getUserId,assignCohort,assignRole,enrolUser,createUser,updateUser) — those still serve other external callers (ULSHelper,AcademyController,MoodleCompetency) unchanged. All new behavior is additive (updateUsersBulk(), thegetCoursesInCategory()memoization) or a refactor ofMoodleSync's internals that preserves the same per-user decision logic, just deferring the HTTP flush.Testing
php -lclean on both changed files../vendor/bin/phpunit: 5/5 passing (unchangedMoodleUserIdMapTestfrom Fix moodle:sync stacking by cutting per-user Moodle HTTP call volume #112; no new local test added since the bulk-flush shape isn't testable without a live/mock Moodle — same limitation noted in Fix moodle:sync stacking by cutting per-user Moodle HTTP call volume #112).moodle:sync 1640903) against real prod Moodle after deploying Fix moodle:sync stacking by cutting per-user Moodle HTTP call volume #112; will re-run after this deploys too.Verification checklist for this PR (real Moodle, no isolated staging Moodle exists — confirmed
current-devhas zero Moodle secrets/DB connection configured)currentprod (same constraint as Fix moodle:sync stacking by cutting per-user Moodle HTTP call volume #112 — there's nowhere else to test against).kubectl -n current exec <api-worker-pod> -- php artisan moodle:sync— manual foreground run, timed. Expect it to actually complete (not need backgrounding past a 10-minute shell timeout).kubectl -n current exec <pod> -- ps -o pid,etime,rss,argsduring the run — confirm memory stays bounded (should not grow much beyond one chunk's worth of accumulated arrays at a time).kubectl -n current logs <pod> --since=1h | grep -i moodle:sync— confirm the new per-chunk log lines appear and aFinished scheduled task: moodle:syncline finally shows up.moodle:syncprocess at anypssnapshot, andStarting/Finishedlog pairs match up every 3 hours.Risks
core_cohort_add_cohort_members/core_role_assign_roles/enrol_manual_enrol_users/core_user_update_userscall. Moodle's Web Service layer is documented as bulk-capable for all of these, but some Moodle configs cap request body size, and the underlying vendoredMoodleRestclient serializes these as form-encoded POST params, which can get large at 1000 entries. If a chunk of 1000 proves too big in practice, the fix is simply lowering the chunk size (either the outerUser::chunk()size or a separate inner flush-batch size) — no structural change needed.🤖 Generated with Claude Code