Skip to content

[ARCH] Plan for scaling Competency Criteria Group associations across recurring course runs #676

Closed

Description

@mgwozdz-unicon

Use Case

As an instance operator running Open edX with courses that rerun every term, I want a validated backend design for handling the accumulation of CompetencyCriteriaGroup records across course reruns, so that the competency authoring API, and any other consumer of the same data (Studio, Competency Management, future Libraries integration, plugin developers calling the API directly), stays performant as terms accumulate.

Description

Current behavior: No CBE code exists yet; this is greenfield work. Per docs/openedx_learning/decisions/0002-competency-criteria-model.rst (Decision 2), a competency's full criteria expression (CompetencyAchievementCriteria) is a tree of CompetencyCriteriaGroup nodes: one root group plus descendant groups and leaf CompetencyCriteria rows, nested up to a frontend-enforced depth of 3 (root, course-scope, leaf/group). A group is scoped to zero courses (course_id null) or to exactly one course run (course_id is a foreign key to openedx_catalog_courserun, a specific run, not a parent course). Because of this nesting, a single course-scoped criteria subtree is not one row: it can already contain multiple CompetencyCriteriaGroup rows.

It is intentional design, not an open question, that a course rerun gets its own new CompetencyCriteriaGroup rows and child CompetencyCriteria rows: these must associate to that specific course run. Every rerun of a course therefore multiplies the number of groups already present for a given competency. A heavily-reused competency, for example a college-wide Gen Ed competency attached to many courses that all rerun every term, can accumulate a large and continuously growing number of groups.

ADR 0002 Decision 2 already commits to: no DB-level row cap on CompetencyCriteriaGroup (growth is expected), pagination being available for authoring/list APIs, and evaluation/read paths being windowed by course run dates by default. What is not yet designed is the concrete backend mechanism needed to keep the Studio authoring panel that lists a competency's groups (see companion UX ticket) both performant and usable at this scale.

Requested change: This spike's central deliverable is a backend implementation plan that can deliver whatever the companion UX design spike decides the authoring panel needs: windowing, filtering, sorting, pagination, some combination of these, or another mechanism entirely. Concretely:

  1. Once the companion UX spike's decision is documented, translate it into a concrete backend design: the query shape, any index changes, and the API contract (parameters and response shape) needed to deliver it. This is the central question this spike exists to answer.
  2. Independent of that decision, review whether ADR 0002 Decision 5's proposed indexes (CompetencyCriteriaGroup(oel_tagging_tag_id, course_id), (parent_id)) are sufficient for the general "list every group for a competency" access pattern, and document any gaps.
  3. Provide a rough order-of-magnitude estimate of how large this can get in the worst case (for example, the Gen Ed scenario), so the design in (1) is checked against a realistic scale, not just the common case.

Out of scope:

  • Implementing the design. This spike documents the plan; a future ticket implements it.
  • Evaluation-time recomputation performance: a separate read path per ADR 0002 Decision 2, already addressed there.
  • The authoring panel's own frontend implementation. The companion UX ticket documents the interaction design; building it is a separate, later frontend-app-authoring ticket.
  • Load testing or benchmarking against synthetic or production-scale data. Not required by this project's contractual scope.

Acceptance Criteria

This is a design spike. The deliverable is design artifacts and documented decisions, not working code. There is no Gherkin and no QA path.

  • Document a concrete backend design (query shape, index changes if any, API contract covering parameters and response shape) that delivers the mechanism decided by the companion UX ticket (windowing, filtering, sorting, pagination, or a combination).
  • Review ADR 0002 Decision 5's proposed indexes against the general "list every group for a competency" access pattern; document any gaps.
  • Document a rough order-of-magnitude estimate of worst-case group count (for example, the Gen Ed scenario), stating assumptions.
  • Decide whether the authoring/list endpoint reuses ADR 0002's evaluation-path date-windowing default or needs its own, with rationale.
  • Document the decision, and note whether an ADR 0002 amendment is warranted to record it.
  • Resolve and document every entry in Open Questions below.
  • Do not include load testing, benchmarking, or a proof-of-concept in this spike's scope.

Open Questions

  • [BLOCKING, depends on companion UX ticket] What mechanism does the frontend need: windowing, filtering, sorting, pagination, or a combination? Most of this ticket's design work cannot proceed until that decision is documented. (owner: designer, via companion ticket)
  • Is an ADR 0002 amendment warranted to record the finalized backend design? (owner: architect)

Context for the Architect

Most of this spike's substantive design work is gated on the companion UX ticket's decision (see Open Questions, above). The index review and the rough sizing estimate can proceed in parallel; the concrete query/API design cannot be finalized until the UX decision lands.

Existing precedent worth reviewing once that decision is known, not a prescribed solution: src/openedx_tagging/ already solves a structurally similar problem (a taxonomy with a large number of tags).

  • src/openedx_tagging/rest_api/paginators.py defines multiple pagination classes tuned to different list sizes (TaxonomyPagination, TagsPagination, DisabledTagsPagination), and TaxonomyTagsView (src/openedx_tagging/rest_api/v1/views.py, roughly lines 659-880) switches between them at request time based on a caller-supplied size threshold.
  • src/openedx_tagging/api.py's in-process functions return lazy, unmaterialized querysets with select_related at hot paths, leaving pagination to the REST view layer.
  • docs/openedx_tagging/decisions/0003-tagging-tree-data-arch.rst addresses a similar tree-scaling question for the tagging model.

Which of these patterns fits, if any, depends entirely on the companion ticket's UX decision. Don't assume a specific technique (caching, for example) is available or the right fit without first confirming it against how this instance of Open edX actually handles comparable problems today.

Context

  • docs/openedx_learning/decisions/0002-competency-criteria-model.rst (Decision 2, Decision 5)
  • docs/openedx_learning/decisions/0001-competency-criteria-location.rst (code location: src/openedx_learning/applets/cbe)
  • src/openedx_tagging/rest_api/paginators.py, src/openedx_tagging/rest_api/v1/views.py (TaxonomyTagsView) — precedent referenced above
  • docs/openedx_tagging/decisions/0003-tagging-tree-data-arch.rst
  • Companion ticket: UX design spike for the "Demonstrate Mastery For" panel's interaction design. Most of this ticket's design work depends on that ticket's decision. Link the two as a GitHub parent/child issue pair once both are created; this is the parent.

Activity

  1. moved this from Needs additional details to Drafting in Competency Criteria and Student Progresson Aug 24, 2026
  2. mgwozdz-unicon commented on Aug 31, 2026

    @mgwozdz-unicon
    ContributorAuthor

    @ormsbee @kdmccormick @bradenmacdonald @jesperhodge Let me know if you have any concerns with this or if you approve.

    Proposal

    (Drafted together with Claude.)

    Backend design for scoping the Competency Criteria Associations panel to whatever course runs are currently visible in the course-browse panel below it (#670, backed by #669, currently nearly through review). The panel gets no independent pagination or filtering of its own: it takes the course keys #669/#670 already resolved, through title search, date range, and pagination, and filters by them. Root/instance-wide groups (course_id is null) are always included regardless of which courses are on screen.

    This needs an additive extension to #681's endpoint (via #682), no new indexes, and a scoped ADR 0002 amendment.

    API contract

    Extend #681 with an optional course_keys query param: a comma-separated list of course run keys, matching the already-shipped blocked-courses endpoint's convention and this codebase's .dict()-based query-param handling.

    course_keys has two modes:

    course_keys Meaning Returns
    Omitted Unfiltered (today's #681 behavior). Not used by the UI; kept for admin/export/debug. Whole tree.
    Present, N keys (N can be 0) The resolved window, root plus one subtree per key. Root subtree plus complete subtrees for those N runs. At N=0: root subtree only. HTTP 200.

    The frontend should always send the param, never omit it, so it never has to distinguish "empty" from "absent."

    Response adds two fields, both counts of CompetencyCriteria leaf rows:

    • criteria_count: count within the current course_keys scope plus root. The numerator.
    • total_criteria_count: unfiltered instance-wide count for the competency. The denominator.

    Course-scoped subtrees in the response are keyed by course_key, not the integer course_id, per CourseRun's own docstring against exposing its primary key.

    Cap course_keys at 100, ten times the browse panel's current page size of 10, and reject over the cap with a 400. This also keeps subtree-completeness free: no page boundary can split a subtree, so #681 doesn't need result-level pagination at all, despite Decision 2's current "pagination is supported for authoring/list APIs" line (see ADR amendment, below).

    Edge cases:

    Ordering: root subtree first, then course-scoped subtrees by course_key, siblings by ordering (semantically meaningful for short-circuit evaluation). If the panel needs to visually match the course list below it, that's a client-side reorder using the key list it already has.

    Query shape

    This is the query plan inside #681's competency-criteria endpoint, once course_keys arrives already resolved by #669/#670. Four queries, constant in count regardless of scope size:

    1. Resolve course-run keys to internal ids: SELECT id FROM openedx_catalog_courserun WHERE course_key IN (:keys). Needs a small bulk resolver added to openedx_catalog.api; today it only exposes get_course_run(course_key), one key at a time (src/openedx_catalog/api_impl.py).
    2. Groups: WHERE oel_tagging_tag_id = :tag AND (course_id IS NULL OR course_id IN (:run_ids)), using the existing CompetencyCriteriaGroup(oel_tagging_tag_id, course_id) index.
    3. Criteria: WHERE competency_criteria_group_id IN (:group_ids), using CompetencyCriteria(competency_criteria_group_id).
    4. Denominator: COUNT(*) joining CompetencyCriteria to CompetencyCriteriaGroup on that same FK, filtered to oel_tagging_tag_id = :tag only, no course filter, via CompetencyCriteriaGroup.oel_tagging_tag_id rather than oel_tagging_objecttag (which allows a null tag and can't express course scope anyway).

    Sequential calls (course-browse, then associations)

    One extra round trip per course-browse page or filter change, not per row: resolve the course window first, then call the associations endpoint with those keys. Ten keys is roughly 350-500 bytes of query string, comfortably below any URL limit, and the 100-key cap above keeps that true even if the UI's own page size changes later.

    Sizing

    Once the panel is scoped to on-screen course keys, the instance-wide total number of groups a competency has accumulated across every rerun no longer bounds what's rendered. It only bounds total_criteria_count (a single indexed count, cheap regardless of scale) and the deliberately non-UI unfiltered #681 path.

    Factor Value
    Courses on screen at once 10 today, capped at 100 by this design
    Worst-case criteria for one course run, this competency ~100 (every gradeable subsection individually tagged; an extreme case)
    Worst-case rendered criteria (10 courses x 100) ~1,000
    Instance-wide total for a heavily-reused competency Unbounded, but only ever touches total_criteria_count, a single indexed count

    Comfortably performant even at these extremes.

    ADR 0002 amendment

    Amend in place with a changelog entry. Scope:

    • Decision 2, retrieval scope: one rule, competency root scope plus a resolved child course-run set, never partial subtrees, regardless of how that set is determined.
    • Decision 2, course_id: denormalized onto every group in a course-scoped subtree (course-level and bottom-tier both), immutable after creation, root always null.
    • Decision 2, "pagination is supported for authoring/list APIs": replace with the bounded-input rule (cap course_keys, don't paginate output), since output pagination would conflict with "do not return partial subtrees."
    Appendix: already-settled background, not new proposals
    course_id covers every course-scoped group, not just the course-level one

    For MVP the tree is exactly three CompetencyCriteriaGroup levels: root (course_id null), then a course-level group, then a bottom-tier group. course_id is denormalized onto the course-level group and its bottom-tier child, both carrying the same value, immutable after creation. This keeps "all nodes where course_id is null" unambiguous (root only) and makes the Query shape's group lookup a single index range scan instead of a parent_id walk per level. Free to settle now: CompetencyCriteriaGroup/CompetencyCriteria don't exist in code yet, only CompetencyTaxonomy does (src/openedx_learning/applets/cbe/models.py), so there's no migration or backfill involved.

    Index review (AC: review Decision 5's indexes against this access pattern)

    No index changes needed; Decision 5's existing indexes already cover it once course_id is denormalized as above.

    • oel_tagging_objecttag(object_id) already exists on the model (db_index=True). Decision 5 lists it as new work; it isn't.
    • The unfiltered case ([BE] GET Competency Criteria & Groups #681 with no filter) isn't an index problem, it's the payload-size problem covered under Sizing. Don't add indexes to fix it.
    • criteria_count costs no extra query, it's the criteria rows the groups/criteria queries already loaded. Only total_criteria_count needs its own.
    Why the course window is resolved client-side, not server-side

    openedx_catalog doesn't do course-visibility; its own API doc says directly that it "does not yet provide any 'list courses' methods." That logic lives in HomePageCoursesViewV2 in openedx-platform, which openedx-core can never import. Resolving the course window client-side first, then calling the associations endpoint with those keys, is the only shape that respects that boundary.

    A pre-existing, unrelated gap this design touches

    openedx_catalog is missing from .importlinter's layer list (root_packages/src_layering currently list openedx_learning, openedx_content, openedx_tagging, openedx_django_lib, openedx_core only), even though CompetencyCriteriaGroup.course_id already FKs into it per ADR 0002. Worth giving it an explicit place in the layer ordering as part of this work, not a blocker for this design.

  3. moved this from Drafting to Ready for Community Review in Competency Criteria and Student Progresson Aug 31, 2026
  4. ormsbee commented on Sep 1, 2026

    @ormsbee
    Contributor

    Resolve course-run keys to internal ids: SELECT id FROM openedx_catalog_courserun WHERE course_key IN (:keys). Needs a small bulk resolver added to openedx_catalog.api; today it only exposes get_course_run(course_key), one key at a time (src/openedx_catalog/api_impl.py).

    If we just want the course run metadata, it seems like it should just return you the actual model objects instead of the keys? So get_course_runs(course_keys) where course_keys is an iterable thing (even a queryset for a sub-select)?

    Anyhow, that nit can be worked out in PR. I think it's fine to go ahead with this.

  5. jesperhodge commented on Sep 1, 2026

    @jesperhodge
    Contributor

    This looks great performance wise and behavior-wise.

    For the API contract, I'm wondering about:

    Empty (?course_keys=) | Resolved window is empty, or not yet resolved. | Root subtree only. HTTP 200.
    

    Can you explain what we need the root subtree here for when the resolved window is empty? What is meant with root subtree?
    Maybe it's just me, but this seems a bit non-intuitive. Can we make the API endpoint more expressive? This "empty" state seems to need careful reading of the API docs to understand, so I'd like to replace it with something more clear.

    Aside from that detail, the what should be done, and the reasoning, look good.

    Just a note for follow-ups from this:

    In terms of language, I have some trouble understanding the writing with how Claude phrased things, which is typical for Claude-written texts right now. For this proposal, that is perfectly fine, but I'd love if documentation or ADR changes that come out of this could become a bit more readable for humans.

  6. mgwozdz-unicon commented on Sep 2, 2026

    @mgwozdz-unicon
    ContributorAuthor

    @jesperhodge The root subtree is the CompetencyCriteriaGroup that corresponds to the competency itself, not to any individual course run.

    course_keys comes back empty when either:

    • the call to fetch courses hasn't come back yet or has failed, or
    • the user searched for courses that don't match anything

    If we define the course_keys parameter as always returning the root plus one subtree per key in course_keys, then at zero keys that's just root plus nothing. So this is effectively just an edge case of the ?course_keys=a,b,c variant. I'll update the proposal to state it that way instead of listing empty as its own condition.

    Yes, I'll try to be very clear in any further documentation and ADR updates that I write.

  7. added a commit that references this issue on Sep 4, 2026
  8. moved this from Ready for Community Review to Community Code Review in Competency Criteria and Student Progresson Sep 4, 2026
  9. added a commit that references this issue on Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions