Skip to content

Batch moodle:sync Moodle writes across users to stop stacking - #113

Merged
Celeo merged 1 commit into
masterfrom
celeo/moodle-sync-batching
Jul 21, 2026
Merged

Batch moodle:sync Moodle writes across users to stop stacking#113
Celeo merged 1 commit into
masterfrom
celeo/moodle-sync-batching

Conversation

@Celeo

@Celeo Celeo commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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 showed moodle:sync runs 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 in sync() 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-blank idnumber). For each of those, the old-#112 code still made:

That's still on the order of 75,000-100,000 HTTP round-trips for a full run. After deploying #112:

  • A manual foreground run (php artisan moodle:sync) ran 36+ minutes without finishing (backgrounded past the shell's timeout).
  • Four consecutive scheduled ticks (06:00, 09:00, 12:00, 15:00 UTC) each logged Starting scheduled task: moodle:sync with zero matching Finished lines.
  • ps on the api-worker pod showed two live overlapping moodle:sync processes (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-user clearUserCohorts()/clearUserRoles() stay per-user since they're direct DB deletes, not HTTP, and were never the bottleneck.
  • handle()'s chunk(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.
  • New VATUSAMoodle::updateUsersBulk(array $items), mirroring the existing assignCohortsBulk/assignRolesBulk/enrolUsersBulk pattern — core_user_update_users was 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 way getCategories() 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.
  • Added 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 via ps and 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.
  • The single-user CLI path (moodle:sync {cid}) is preserved with the same create-or-update decision as before, just routed through computeSyncItems() and flushed immediately as single-item bulk calls (functionally identical to the old immediate-flush behavior, aside from one intentionally-accepted redundant updateUsersBulk() call after createUser() for brand-new users — harmless since createUser() already sets those same fields).

Behavior preservation

No changes to Kernel.php, withoutOverlapping(120), or the signature of any existing single-item VATUSAMoodle method (getUserId, assignCohort, assignRole, enrolUser, createUser, updateUser) — those still serve other external callers (ULSHelper, AcademyController, MoodleCompetency) unchanged. All new behavior is additive (updateUsersBulk(), the getCoursesInCategory() memoization) or a refactor of MoodleSync's internals that preserves the same per-user decision logic, just deferring the HTTP flush.

Testing

Verification checklist for this PR (real Moodle, no isolated staging Moodle exists — confirmed current-dev has zero Moodle secrets/DB connection configured)

  • Deploy to current prod (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).
  • Watch kubectl -n current exec <pod> -- ps -o pid,etime,rss,args during 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 a Finished scheduled task: moodle:sync line finally shows up.
  • After the next scheduled ticks, confirm no more than one live moodle:sync process at any ps snapshot, and Starting/Finished log pairs match up every 3 hours.
  • Spot-check a sampled user's cohorts/roles/enrolments in Moodle match what the old per-item path would have produced (no behavioral drift from cross-user batching).

Risks

  • Bulk-call size. Each chunk can send up to 1000 array entries in a single core_cohort_add_cohort_members/core_role_assign_roles/enrol_manual_enrol_users/core_user_update_users call. 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 vendored MoodleRest client 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 outer User::chunk() size or a separate inner flush-batch size) — no structural change needed.
  • Partial-batch failure semantics. A single bad record in a 1000-entry bulk call could fail the whole chunk's call for that item type. The new log-and-rethrow wrapper will at least surface which chunk and which call failed immediately, rather than silently completing with partial data — but a failure now affects up to 1000 users' worth of one item type at once, versus one user before. Given Moodle's array-based Web Service functions are documented as processing each array entry independently (a bad entry shouldn't 500 the whole call), this is believed low-risk, but hasn't been confirmed against Moodle's actual error-handling behavior at this batch size.
  • Still not a hard runtime guarantee. If ~104 HTTP calls still doesn't clear the 120-minute window (unlikely, but the manual dev-side timing test that would confirm this wasn't feasible pre-merge — no isolated Moodle to test against), the next lever is Lever 3 from the original plan: a structural stacking backstop (heartbeat lock independent of the scheduler mutex) rather than continuing to chase raw throughput.

🤖 Generated with Claude Code

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
Celeo merged commit 5e89b0f into master Jul 21, 2026
3 checks passed
@Celeo
Celeo deleted the celeo/moodle-sync-batching branch July 21, 2026 16:04
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>
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