Conversation
Rewrites import_courselibrary_search::get_searchsql() so the main query no
longer joins {course_modules}, every activity table, {tag_instance}, {tag}
or {customfield_data} on every page load. Tag, course-module-tag, activity
name/intro and custom-field matching now happen via small index-friendly
subqueries combined into c.id IN (... UNION ...). Capability and custom-
field filters are EXISTS clauses, removing the row fan-out that previously
required DISTINCT.
search() now caches the total in $totalcount_full and runs a direct
COUNT(*) against the lean WHERE; get_total_course_count() reuses the
cached value so the search SQL runs twice per request instead of three
times.
No schema, settings, template or JS changes. Functional parity preserved:
same WHERE semantics, same disableactivitydescriptionsearch honour, same
sort/filter/capability behaviour.
Moves the per-course render-time work out of the initial library page load. - export_for_template() no longer calls get_course_contents() up front. The accordion is rendered as an empty shell with a data-courseid hook; the contents are fetched via a new fragment when the user first clicks "Show contents". On large sites this eliminates ~all of the 5000+ DB reads and 10000+ filter instantiations caused by iterating every activity in every course on the page. - New fragment get_library_coursecontents renders the accordion partial (local_kickstart_pro/import_course_contents) for a single course. - Tags and custom-field instance data for the page are loaded in batch before the export loop, replacing per-course N+1 queries. - Relevance scoring is rewritten as a single batch pass (calculate_relevance_scores_batch) that loads tags, favourites and customfield data once for current course + result set, replacing the per-result get_relevance_score() that re-fetched the current course and re-ran find_all_favourites on every iteration. - AMD module toggles the accordion and lazy-fetches the fragment on first expand; subsequent clicks just flip visibility. Build produced with grunt amd.
Two follow-up reductions to the per-page render query count.
- The search SELECT now includes c.category, c.idnumber, c.startdate,
c.summary and c.summaryformat. core_course_list_element::__get()
lazy-loads any course column missing from the record via
$DB->get_field('course', ...), so on the default-config page
($courseinfo->category is read to build the category path) every
course on the page issued one extra row read. Selecting the columns
up-front removes 10-30 reads depending on which display fields are
configured.
- load_customfields_for_courses() now runs a single
SELECT FROM {customfield_data} WHERE instanceid IN (...)
for the visible page and builds data_controller instances locally via
data_controller::create($id=0, $record, $field), passing the
pre-fetched field controller so no further DB queries are issued.
Previously the handler's get_instance_data() was called once per
course, costing ~one read per course.
- Rename member variable $totalcount_full to $totalcountfull (Moodle naming convention disallows underscores in member variable names). - Drop column-alignment spaces in get_search_where() so each sql_like() argument has a single space after the comma. - Wrap the multi-line if condition in calculate_relevance_scores_batch() so the first expression sits on its own line after the opening parenthesis (PSR12.ControlStructures.ControlStructureSpacing). - Capitalise the inline comment introducing the static cache note on core_course_category::get(). - Reformat the debugging() call in load_customfields_for_courses() to split arguments across lines with the closing paren on its own line (PSR2.Methods.FunctionCallSignature). No functional change.
Adding c.startdate, c.idnumber, c.summary, c.summaryformat and c.category to the search SELECT (commit 28aad27) avoided per-course lazy loads through core_course_list_element::__get(), but as a side effect populated $course->startdate / $course->idnumber on the result row before export_for_template() decided whether to render them. On sites whose displaycourselibraryfields setting did not include "startdate" or "idnumber", the if-blocks that called userdate() / read $courseinfo->idnumber never ran, so the raw integer timestamp (or raw idnumber string) stayed on $course and the Pro template's {{#startdate}} / {{#idnumber}} truthiness check then printed it. m45 hid the bug because its display configuration included "startdate". Clear those five columns from $course immediately after constructing the core_course_list_element wrapper. The wrapper holds its own copy of the record, so $courseinfo->startdate / ->idnumber / ->category still work as before; only the public properties on $course are reset, which means the template renders idnumber/startdate strictly when the display-field branches populate them.
Member
Author
|
Task linked: KICK-300 Course library performance improvements |
|
Please merge this PR to to a new Kickstart Pro release that is still Moodle 4.5 LTS compatible. We have a mid-size Moodle installation (30k courses) and Course Library is pretty much unusable with current 1.5 R6 version: loading and updating course list takes 1-2 minutes or causes database error. |
Merged
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.
Context
The Course Library page (kickstart format,
?nav=courselibrary) takes 10+ seconds to render on a customer site with 60k courses and 2.5M activities. This PR reduces that to under 1 second.mainKICK-300This depends on a matching change in
local_kickstart_pro(alsoKICK-300). The Pro template splits the per-section accordion into a partial so the format-side fragment can render it on demand. Both PRs must be merged together — format ships the fragment that fills the partial; Pro ships the partial.Summary of changes
Five commits, each one a discrete optimisation. They build on each other but each is meaningful on its own.
1.
84aa7baRewrite the search SQLclasses/output/import_courselibrary_search.phppreviously built one large query that always LEFT-JOINed{course_modules}+ a UNION ALL across every activity table +{tag_instance}× 2 +{tag}× 2 +{customfield_data}+{customfield_field}, then deduped withDISTINCT c.id. With an empty search box the placeholder LIKE'%%'matched everything, so the database materialised the full Cartesian product on every landing page. The same query was executed three times per request (data + two count subqueries).After:
{course},{context}and{user_lastaccess}— no DISTINCT, no fan-out joins.EXISTSsubquery (no row multiplication).c.id IN (... UNION ...)clause where each branch is a small index-friendly query (course fields, course tags, course-module tags, one query per activity table). Thedisableactivitydescriptionsearchsetting still gates the activity-intro branch.EXISTSper filter.SELECT COUNT(*) FROM ... WHEREagainst the lean query and is cached so the SQL runs twice per request instead of three times.Functional parity preserved: same WHERE semantics, same sort/filter/capability behaviour, same
disableactivitydescriptionsearchhandling.2.
276caccLazy-load course contents + batch render-path queriesclasses/output/import_course_list.php,classes/output/import_courselibrary_search.php,lib.php,amd/src/formatkickstart.jsexport_for_template()no longer callsget_course_contents()for every course on the page. The Pro template now emits an empty accordion shell withdata-courseid/data-loadedattributes; the new fragmentformat_kickstart_output_fragment_get_library_coursecontentsrenders the accordion partial on first expand. On large sites this single change removes ~all 5000+ DB reads and ~10000+ filter instantiations the previous loop produced.Inside
export_for_template:WHERE itemid IN (...)query for the page instead of\core_tag_tag::get_item_tags_array()per course).In
search():calculate_relevance_scores_batch()replaces the per-resultget_relevance_score()call. Runs at most 4 queries for the whole page (current course, tags batch, customfield batch, favourites) instead of N×~10. Same scoring formula; only the query pattern changed.get_relevance_score()left intact in case third-party callers exist.The AMD module wires the first click of
.show-content-buttontoFragment.loadFragment(...)andTemplates.appendNodeContentsinto the empty shell. After that click,data-loadedis set so subsequent clicks only flipd-none. The.import-activityhandler is rebound on the freshly loaded partial. Build artefacts produced withgrunt amd.3.
28aad27Batch customfield data + select extra course columnsc.category, c.idnumber, c.startdate, c.summary, c.summaryformat.\core_course_list_element::__get()lazy-loads any column missing from the wrapped record via$DB->get_field('course', $name, ...); with the default config (categorypathdisplayed) every course on the page hit this forcategory. Selecting the columns up-front removes the lazy load.load_customfields_for_courses()rewritten to run oneSELECT FROM {customfield_data} WHERE instanceid IN (...)and build\core_customfield\data_controllerinstances locally viadata_controller::create(0, $row, $field)with the pre-fetched field controller — no further DB calls per row. Previously the handler'sget_instance_data()was called per course.4.
88347c2Fix Moodle Code Checker findingsPure style, no behaviour change. Renames
$totalcount_full→$totalcountfull(Moodle disallows underscores in member variable names), collapses column-alignment whitespace, wraps a multi-lineifso the first expression sits on its own line, capitalises an inline comment, reformats adebugging()call to follow PSR2.5.
affe3ecFix raw startdate/idnumber leaking into the library templateRegression from commit 3: now that
c.startdateandc.idnumberare selected up-front, the raw integer timestamp / idnumber string stays on$coursewhen the admin'sdisplaycourselibraryfieldssetting doesn't include them. The Pro template's{{#startdate}}…{{startdate}}…{{/startdate}}block then prints the raw value (a Unix timestamp leaked into the UI as a 10-digit integer).Fix: immediately after constructing
$courseinfo = new \core_course_list_element($course),unset($course->idnumber, $course->startdate, $course->summary, $course->summaryformat, $course->category). The list-element wrapper keeps its own copy of the record, so$courseinfo->startdateetc. still work; only the public properties on$courseare reset, which means the template renders these fields strictly when the display-field branches populate them.What was deliberately NOT changed
relevance. The Pro entry point (kickstart_propage.php:203) still hard-codes'relevance'.get_relevance_score()is kept on the class but no longer called from this plugin. Removable in a follow-up if no external callers exist.format_string(\$course->fullname, ['context' => \context_course::instance(\$course->id)])still loads its own filter set per course context. Removing it would change filter semantics for sites with per-category filter configuration — out of scope.Test plan
moodle/backup:backuptargetimport. Confirm pagination total + result set match the previous version.disableactivitydescriptionsearchand confirm intro matches stop returning results.displaycourselibraryfieldsdoes not includestartdate, the start date area no longer renders the raw Unix timestamp.Deploy / merge
Merge this in lockstep with the
KICK-300PR onmoodle-local_kickstart_pro. Either order is fine, but do not deploy one without the other: