Replace the b2b_contracts prefetch with contract-id arrays in /api/v2/courses/ - #4019
Draft
rhysyngsun wants to merge 1 commit into
Draft
rhysyngsun wants to merge 1 commit into
rhysyngsun wants to merge 1 commit into
Conversation
The 2026-09-23 production traces for GET /api/v2/courses/<pk>/?live=True
show 1020ms wall against 22ms of SQL across 15 queries. 929ms of it sits
in one zero-SQL window between the b2b_contracts prefetch SELECT and the
enrollment_modes prefetch SELECT.
Django pops prefetch lookups off a stack, so that window is the tail of
prefetch_one_level() for b2b_contracts and can be nothing else. Two costs
compound there, both pure Python: ContractPage is a Wagtail Page and a
ClusterableModel - whose __init__ rescans _meta.get_fields() twice, per
instance - so every (run, contract) pair hydrates the most expensive model
class in the project; and a Prefetch without to_attr builds a related
manager and a queryset clone per run on top. The two sibling prefetches
over the same 149 runs, both declared with to_attr, cost 3.5ms and 8.1ms.
Neither traced request passed org_id or contract_id, so every ContractPage
was discarded unread: get_filtered_runs only consults contracts on those
branches, and get_next_run_id only calls get_first_unexpired_b2b_run when
they are in context.
Both methods read exactly two things off a contract, its pk and its
organization_id, so hand them over as ARRAY(subquery) columns on the
courseruns query instead. No model instances, no related managers, and one
fewer query. active_contract_id_annotations() goes through
ContractPage.active_objects to keep the active/in-window filtering the M2M
related manager was applying, and both aliases shadow cached_property
fallbacks on CourseRun for callers that do not annotate.
v3 enrollments got the same treatment: "run__b2b_contracts" was prefetched
but never read, and select_related("run__b2b_contract") hydrated a
ContractPage per enrollment to read one integer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpenAPI ChangesShow/hide changesUnexpected changes? Ensure your branch is up-to-date with |
This branch has not been deployed
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.
What are the relevant tickets?
Part of https://github.com/mitodl/hq/issues/11517
Follows #4013, which narrowed this prefetch. This removes it.
Description (What does it do?)
Stops
GET /api/v2/courses/instantiatingContractPageobjects at all.#4013 narrowed the
b2b_contractsprefetch to.only("organization_id"), which cut the columns. A production trace taken after it deployed shows the cost was never mostly in the columns:29d74aGET /api/v2/courses/123/?live=True88dcddGET /api/v2/courses/168/?live=True929 ms of the first sits in one zero-SQL, zero-Redis, zero-HTTP window, for a course with 149 runs:
Three sibling prefetches over the same 149 instances. The two declared with
to_attrcost 3.5 ms and 8.1 ms of post-query Python; the one without costs 929 ms.Changes:
Prefetch("b2b_contracts", …)with twoArraySubqueryannotations,b2b_contract_idsandb2b_contract_org_ids, built by a newactive_contract_id_annotations()incourses/utils.py.cached_propertyfallbacks onCourseRunfor callers that don't annotate, same shadowing trick asb2b_contract_organization_idfrom Stop hydrating B2B ContractPage rows in /api/v2/courses/ #4013.Course.get_filtered_runsandCourse.get_first_unexpired_b2b_runmatch against the arrays instead of iteratingrun.b2b_contracts.all().prefetch_related("run__b2b_contracts"), which nothing read, and replaceselect_related("run__b2b_contract")with the same organization-id annotation.One fewer query on the list and detail routes.
How can this be tested?
docker compose exec web pytest -n logical courses b2bWhat I ran, all passing: 1030
coursestests, 608b2b(1 skipped),makemigrations --check --dry-run, the OpenAPI spec check (no diff), andpre-commit run --all-files. The drf-lint baseline shrinks by one entry — the v3 change removes an ORM traversal.New tests:
test_b2b_contract_id_arrays_prefer_annotation/_without_annotation— the annotation shadows thecached_propertywith zero queries, and the lazy path still resolves.test_b2b_contract_id_arrays_exclude_out_of_window_contracts— an expired contract is excluded on both paths. This is the guard onactive_objects:ContractPage's only local manager isActiveContractManager, so the M2M related manager this replaces has always filtered to active, in-window contracts, andContractPage.objects— Wagtail's inheritedPageManager— would silently widen it.test_get_filtered_runs_matches_contracts_on_both_paths— same runs matched annotated and unannotated, parametrized overorg_idandcontract_id.test_courses_list_never_queries_the_contract_m2m/test_course_detail_never_queries_the_contract_m2m— no request on either route may issue theb2b_contractsprefetch, on any filter.To reproduce the benchmark,
.bench/(untracked) A/Bs both routes against one seeded database:BENCH_EXTRA_ENV='MITX_ONLINE_USE_S3=False' .bench/run.sh mainAdditional Context
Local benchmark,
main(5f7b8c6c) vs this branch, same seeded database, refs switched around it.response_bytes,count,resultsandruns_serializedmatch between arms.GET /api/v2/courses/<pk>/?live=True— 149 runs on the course, 12 contracts/run, 1764 (run, contract) pairs:GET /api/v2/courses/?contract_id=…&org_id=…&page_size=200— 25 courses, 400 runs, 2 contracts/run:Per-query attribution, median of 7 traced repeats, SQL + the gap after it:
The local numbers do not reproduce production's magnitude, and I can't claim they will. That window is 61–65 ms here against 929 ms in production. It scales with (run, contract) pairs — 294 pairs → 21.3 ms, 1764 pairs → 64.8 ms on the same machine — so the mechanism reproduces and the direction is certain, but I don't know production's contracts-per-run and couldn't seed to it. Worth pulling a fresh trace for a high-run-count course after this deploys rather than taking the local delta as the expected win.
Why a prefetch over a Wagtail Page is so expensive
Two compounding costs, both pure Python, both landing after the OTel span closes — the psycopg span wraps
execute(), so row-to-model conversion inlist(rel_qs)happens outside it.Model instantiation.
ContractPageis a WagtailPageand aClusterableModel.ClusterableModel.__init__calls bothget_all_child_relations()andget_all_child_m2m_relations(), each of which runs a list comprehension overmodel._meta.get_fields(). Neither is cached, and for aPagesubclass that list is 66 entries. Measured here, instantiating from raw values:get_fields()EnrollmentModeCourseRunContractPageOrganizationPage.only()cut the columns, not the object count — which is why #4013 helped the row-transfer cost and left this.Per-instance queryset construction.
prefetch_one_leveltakes themanager._apply_rel_filters(lookup.queryset)branch once per run — a queryset clone plus a join-resolving.filter()against a Wagtail MTI model, 149 times. Theto_attrbranch is a baresetattrand skips all of it. That is the difference between the 929 ms prefetch and its 3.5 ms and 8.1 ms siblings.Why the window can only be this prefetch. Django 5.2 pops prefetch lookups off a stack rather than a queue, so nested lookups run depth-first in exactly the order the trace shows:
departments → courseruns → b2b_contracts → enrollment_modes → products → topics → instructors → variants. The gap sits betweenb2b_contractsandenrollment_modes.Implementation notes
Emitted SQL — the
active_objectspredicate survives into the subquery:ArraySubquery, notArrayAgg.ArrayAggover the LEFT JOIN would need aGROUP BYon the courseruns select list — the sameGROUP BYthe existingExists()annotations were chosen to avoid. Two correlated subqueries keep the query shape flat.ARRAY(subquery)yields[]rather than NULL for no rows, so the Python match sites need no None guard.contract_group_idsdeliberately left alone. It looks like it should reuse the newcached_property, but it is read by them2m_changedreceiver incourses/signals.pythat fires aroundb2b_contracts.add(). A cached value would go stale betweenpre_addandpost_add, so it stays a plain@propertythat re-queries.courses/views/internal/needs no change. The ETL viewset builds the sameproducts/enrollment_modesprefetches but never prefetchedb2b_contracts, so it never had this problem.