Fix moodle:sync piling up overlapping instances via category-fetch N+1 - #111
Merged
Conversation
The api-worker pod was found running 4 concurrent, still-alive moodle:sync processes stacked ~3 hours apart (elapsed 11h41m/8h41m/5h41m/2h41m), each holding ~55-63MB RSS. moodle:sync is scheduled every 3 hours with a 120-minute withoutOverlapping() lock; once a run's wall-clock time exceeds that lock, the scheduler treats the slot as free and starts a new instance on the next tick even though the previous one is still running. Nothing kills the stale runs, so they pile up indefinitely. Root cause: MoodleSync::sync() calls VATUSAMoodle::getCategoryFromShort() 5 times per user, and every call re-fetches Moodle's entire category tree live via getCategories() — a full HTTP round-trip that's identical on every call within a single run. For a large user table this N+1 is almost certainly what pushes total runtime past the 120-minute lock. Per api/CLAUDE.md, the 120/60-minute withoutOverlapping() windows on moodle:sync/moodle:competency were deliberately shortened (commit a324474) so an orphaned lock self-heals in ~1-2h instead of Laravel's 24h default - lengthening them back would reintroduce that problem, so this fix instead makes the job's real runtime reliably fit inside the existing window. - VATUSAMoodle::getCategories() now memoizes its result for the lifetime of the instance. The same instance is injected once into MoodleSync and reused across its whole User::chunk() loop, so this collapses up to 5x live fetches per user down to 1 for the entire run, with no change to which categories/roles get assigned. - VATUSAMoodle::request() now bounds every Moodle HTTP call to 30s via a scoped default_socket_timeout (save/restore per call, since that ini setting is process-global and also touches predis). The vendored MoodleRest package has no explicit timeout and otherwise falls back to PHP's 60s default. - Kernel.php's before/after scheduler hooks now bridge elapsed time via Cache instead of a shared closure variable, since runInBackground() events run their 'after' hook in a separate schedule:finish process with no memory in common with schedule:work. Logs a WARN if moodle:sync/ moodle:competency exceed 75% of their respective lock windows (90/45 min), so a future regression surfaces as a log line instead of another live-pod investigation. Explicitly out of scope: the withoutOverlapping() windows themselves, MoodleCompetency.php (doesn't share the getCategories() N+1 and wasn't observed stacked), and parallelizing the remaining per-user Moodle calls (no production data to justify it yet - the new WARN log exists to catch that if the N+1 fix alone isn't sufficient). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contributor
Author
|
This seems to have not fixed the issue. |
4 tasks
Celeo
added a commit
that referenced
this pull request
Jul 21, 2026
…112) PR #111 memoized getCategories() and added a 30s per-call HTTP timeout, on the theory that a category-fetch N+1 was what pushed a single moodle:sync run past its 120-minute withoutOverlapping() lock. Deployed 2026-07-19T21:29Z; two days later the api-worker pod still had 6 concurrent moodle:sync processes stacked ~3h apart (elapsed 17h47m down to 2h47m), so that fix did not address the dominant cost. Root cause: MoodleSync::handle() walks the *entire* VATUSA user table and makes one core_user_get_users HTTP call per user just to check "is this CID in Moodle?" - the large majority return "not found", so this existence check alone is tens of thousands of sequential round-trips per run. Every user who *is* found in Moodle then triggers several more one-item-at-a-time write calls (cohorts, roles, enrolments), with staff users triggering dozens via a nested category/course enrolment loop. Per api/CLAUDE.md, the 120-minute withoutOverlapping() window on moodle:sync is deliberately short (commit a324474) so an orphaned lock self-heals in ~1-2h instead of Laravel's 24h default - widening it would undo that, so this fix instead cuts real runtime back under the window using Moodle's existing bulk Web Service endpoints. - VATUSAMoodle::getAllUserIdMap() replaces the per-user getUserId() HTTP existence check with a single query against the Moodle DB connection this class already uses elsewhere, building a full CID -> Moodle-id map up front. MoodleSync::handle() now does an in-memory lookup against that map instead of an HTTP call per VATUSA user, and passes the known id into sync() so its own redundant second getUserId() call is skipped too. The single-user `moodle:sync {cid}` CLI path is unchanged (still create-or-update via getUserId()). - VATUSAMoodle gains assignCohortsBulk()/assignRolesBulk()/ enrolUsersBulk(), thin wrappers that send the same core_cohort_add_cohort_members/core_role_assign_roles/ enrol_manual_enrol_users calls with a full array of items instead of a single-element array. The existing single-item methods are left untouched (other callers depend on them). MoodleSync::sync() now collects each user's cohort/role/enrolment items and flushes each as one bulk call at the end, instead of one HTTP round-trip per item. No changes to Kernel.php scheduling, withoutOverlapping() values, or any existing VATUSAMoodle method signature. 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.
Rationale
Exec'd into the live prod
api-workerpod and found 4 concurrentmoodle:syncprocesses running simultaneously, none finished, with elapsed times of 11h41m, 8h41m, 5h41m, and 2h41m — i.e. a new instance had started every ~3 hours (its schedule interval) despite the previous ones never completing. Each held ~55-63MB RSS, so roughly 230-290MB of the pod's memory was just stacked duplicate jobs. This was discovered while investigating whyapi-worker's real CPU/memory usage ran persistently above its Kubernetes resource request (fixed separately via a gitops resource bump, but that only addressed the symptom, not this root cause).What was wrong
moodle:syncis scheduled inapp/Console/Kernel.php:MoodleSync::handle()does a fully sequentialUser::chunk(1000, ...)loop — one Moodle HTTP call at a time, no concurrency. Once a run's wall-clock time exceeds the 120-minutewithoutOverlapping()lock, Laravel's scheduler considers the slot free and starts a new instance on the next 3-hour tick even though the previous one is still alive. Nothing ever kills the stale runs, so they keep stacking.The dominant contributor to the excess runtime: inside
MoodleSync::sync()(called once per user),VATUSAMoodle::getCategoryFromShort()is called 5 times per user (STU/TA/FACCBT/INS role-assignment blocks), and every single call internally callsgetCategories()— a full, uncached HTTP round-trip to Moodle'score_course_get_categories, returning the entire category tree. That tree is static for the duration of one run, so this is a severe N+1: for every user in a potentially large table, the same full tree gets re-fetched from Moodle live, sequentially, up to 5 times.Constraint we had to respect: per
api/CLAUDE.md, the 120/60-minutewithoutOverlapping()windows onmoodle:sync/moodle:competencywere deliberately shortened in a prior fix (a324474) specifically so an orphaned lock (pod killed mid-run, before it reachesschedule:finish) self-heals in ~1-2h instead of Laravel's 24h default. Lengthening those windows back would reintroduce that problem, so this fix makes the job's real runtime reliably fit inside the existing window instead of loosening the lock.What we changed
app/Classes/VATUSAMoodle.phpgetCategories()now memoizes its result for the lifetime of the instance. The sameVATUSAMoodleinstance is injected once intoMoodleSyncvia its constructor and reused across the entireUser::chunk()loop, so this is correctly scoped — it collapses up to 5x live fetches per user down to a single fetch for the whole run, with no change to which categories/roles get assigned.getCategory()andgetAllSubcategories()are untouched — both hit Moodle with server-side filtering criteria and must stay live.request()is now overridden to bound every Moodle HTTP call to 30s via a save/restore-scopeddefault_socket_timeout. The vendoredllagerlof/moodlerestpackage'srequest()usesfile_get_contents()/stream contexts with no explicit timeout, so it silently fell back to PHP's 60s ini default (not guaranteed). We scope the ini change tightly around each individual call — not once for the whole process — sincedefault_socket_timeoutis process-global and this app'spredis(Redis) client also consults it.app/Console/Kernel.phpbefore/afterscheduler hooks to bridge elapsed time viaCacheinstead of a shared closure variable. Non-obvious subtlety: bothmoodle:syncandmoodle:competencyuse->runInBackground(), and for background events Laravel runs theafterhook in a separate, freshly-bootedschedule:finishprocess with no shared memory with theschedule:workprocess that ranbefore— ause (&$var)closure wouldn't have worked here.WARNif a run exceeds 75% of its lock window — 90 min formoodle:sync(120min lock), 45 min formoodle:competency(60min lock) — so a future regression surfaces as a log line instead of requiring another live-pod investigation. Other scheduled commands still get a free elapsed-time annotation on their "Finished" log line but no threshold, since picking one for jobs not implicated in this bug would be guessing.Explicitly out of scope: the
withoutOverlapping()window values themselves;MoodleCompetency.php(doesn't callgetCategoryFromShort()/getCategories()and wasn't observed stacked); parallelizing the remaining per-user Moodle calls (getUserId,updateUser/createUser,assignCohort,assignRole, etc.) — no production numbers on user-table size or per-call Moodle latency to justify that yet. The new WARN log exists to catch it cheaply if the N+1 fix alone turns out to be insufficient.How we verified locally
No
composer/vendoravailable outside a container, so verification usedcompose.dev.yml(MySQL + Redis +artisan serve). Getting that stack running exposed three pre-existing, unrelated gaps in the dev setup (fixed transiently for verification only, not part of this diff — worth their own follow-up):bootstrap/cachedoesn't exist on a fresh checkout (gitignored) but the bind-mounted container needs it writable.composer install(which boots the app viapackage:discover) before copying.env.example→.env, so it always fails on a truly fresh clone with noAPP_KEY.config/database.phphardcodes'scheme' => 'tls'for Redis with no env override, but the devredis:7-alpinecontainer is plaintext, so Predis hangs on the TLS handshake on every artisan boot.Once the stack was up:
php -lon both changed files — no syntax errors../vendor/bin/phpunit— 3/3 existing smoke tests pass, unaffected.php artisan schedule:list— parses cleanly with the new hook signatures;moodle:sync/moodle:competencystill show their existing due times and mutex locks.php artisan migrate— ran clean against the compose MySQL.VATUSAMoodlesubclass that interceptsrequest(): 5 calls acrossgetCategories()/getCategoryFromShort()collapsed to exactly 1 livecore_course_get_categoriesfetch — confirms the N+1 fix.Cachemarker): elapsed time computed correctly (95 min),Log::warningfired for exceeding the 90-minutemoodle:syncthreshold, and the cache marker was cleaned up afterward.How to verify post-deploy
Per
api/CLAUDE.md's existing diagnostics, across 2-3 full 3-hourmoodle:synccycles:Confirm only one
moodle:syncprocess is ever alive at a time (no more 3h-apart stacking), the "Finished" log line reports a duration comfortably under 120 minutes, RSS stays in the same ~55-63MB range, and noWARNfires. If aWARNdoes fire, that's a live signal the N+1 fix alone wasn't sufficient and the parallelization follow-up (explicitly deferred above) is needed.🤖 Generated with Claude Code