Skip to content

Commit 78c605f

Browse files
authored
Merge branch 'main' into jesperhodge/feat--640-cbe-app-foundation
2 parents 9ceadc3 + c02dbce commit 78c605f

38 files changed

Lines changed: 800 additions & 1270 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ jobs:
3636
--health-timeout 5s
3737
--health-retries 3
3838
steps:
39-
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
39+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
4040
- name: setup python
41-
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
41+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
4242
with:
4343
python-version: ${{ matrix.python-version }}
4444

.github/workflows/lint-imports.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ jobs:
1313
name: Lint Python Imports
1414
runs-on: ubuntu-latest
1515
steps:
16-
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
16+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1717
- name: setup python
18-
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
18+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
1919
with:
2020
python-version: '3.12'
2121

.github/workflows/pypi-publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ jobs:
1111

1212
steps:
1313
- name: Checkout
14-
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
14+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1515
- name: setup python
16-
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
16+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
1717
with:
1818
python-version: 3.12
1919

docs/openedx_content/decisions/0005-serving-static-assets.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
5. Serving Course Team Authored Static Assets
44
==============================================
55

6+
Status
7+
------
8+
9+
Accepted in principle. Pending implementation.
10+
611
Context
712
--------
813

docs/openedx_learning/decisions/0002-competency-criteria-model.rst

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Decision
135135
7. ``rule_payload``: JSON payload keyed by ``rule_type`` to avoid freeform strings. It is structured JSON (not arbitrary freeform data): each ``rule_type`` defines the allowed payload shape and required keys, and validation enforces this contract. JSON is used instead of fixed columns like ``op``, ``value``, and ``scale`` so that future rule types (for example, ``MasteryLevel`` thresholds or plugin-defined evaluators such as CEL-based rules) can add their own fields without repeated schema migrations or many nullable columns. Examples:
136136

137137
1. ``Grade``: ``{"op": "gte", "value": 0.75, "scale": "percent"}``. Allowed ``op`` values: ``gte``, ``lte``, ``eq``. ``value`` must be a fraction between 0.0 and 1.0 inclusive, matching the platform's existing fractional grade representation, not a 0-100 scale.
138-
8. ``archived``: Boolean, defaults to false. Set instead of deleting a profile that is no longer wanted. Archived profiles are hidden from authoring and new associations but remain queryable, so existing ``CompetencyCriterion`` rows and learner status history stay resolvable.
138+
8. ``archived``: Boolean, defaults to false. Set instead of deleting a profile that is no longer wanted. Archived profiles are hidden from authoring and new associations but remain queryable, so existing ``CompetencyCriterion`` rows and learner status rows stay resolvable.
139139

140140
A check constraint requires that at most one of ``organization_id``, ``course_id``, and ``competency_taxonomy_id`` is non-null on any row, matching the scoping rule above.
141141

@@ -240,9 +240,9 @@ Decision
240240
3. ``oel_tagging_objecttag(object_id)``
241241
4. ``CompetencyCriteria(oel_tagging_objecttag_id)``
242242
5. ``CompetencyCriteria(competency_criteria_group_id)``
243-
6. ``StudentCompetencyCriteriaStatus(user_id, competency_criteria_id)``
244-
7. ``StudentCompetencyCriteriaGroupStatus(user_id, competency_criteria_group_id)``
245-
8. ``StudentCompetencyStatus(user_id, oel_tagging_tag_id)``
243+
6. ``StudentCompetencyCriteriaStatus(user_id, competency_criteria_id)`` (unique -- each learner status table holds exactly one row per learner and target entity, updated in place; see ADR 0003 Decision 5)
244+
7. ``StudentCompetencyCriteriaGroupStatus(user_id, competency_criteria_group_id)`` (unique)
245+
8. ``StudentCompetencyStatus(user_id, oel_tagging_tag_id)`` (unique)
246246
9. ``CompetencyRuleProfile(scope_code)`` (unique -- at most one profile per distinct scope value; a plain unique constraint on the three raw nullable scope columns would not enforce this, since SQL never treats two ``NULL`` values as equal and this project's MySQL backend does not support the conditional/partial unique indexes that would otherwise route around that; see the ``scope_code`` column in Decision 3)
247247
10. ``CompetencyMasteryStatuses(status)`` (unique)
248248

@@ -279,23 +279,26 @@ Decision
279279
2. ``competency_criteria_id``: Foreign key to ``CompetencyCriterion.id``
280280
3. ``user_id``: Foreign key pointing to user_id (presumably the learner's id, although it appears that it is possible for staff to get grades as well) in ``auth_user`` table
281281
4. ``status_id``: Foreign key to ``CompetencyMasteryStatuses.id``
282-
5. ``created``: The timestamp at which the student's criterion status was set.
282+
5. ``created``: The timestamp at which the student's criterion status row was first written.
283+
6. ``modified``: The timestamp at which the student's criterion status was last updated.
283284

284285
3. Add a new database table for ``StudentCompetencyCriteriaGroupStatus`` with these columns:
285286

286287
1. ``id``: unique primary key
287288
2. ``competency_criteria_group_id``: Foreign key to ``CompetencyCriteriaGroup.id``
288289
3. ``user_id``: Foreign key pointing to user_id (presumably the learner's id, although it appears that it is possible for staff to get grades as well) in ``auth_user`` table
289290
4. ``status_id``: Foreign key to ``CompetencyMasteryStatuses.id``
290-
5. ``created``: The timestamp at which the student's criteria-group status was set.
291+
5. ``created``: The timestamp at which the student's criteria-group status row was first written.
292+
6. ``modified``: The timestamp at which the student's criteria-group status was last updated.
291293

292294
4. Add a new database table for ``StudentCompetencyStatus`` with these columns:
293295

294296
1. ``id``: unique primary key
295297
2. ``oel_tagging_tag_id``: Foreign key pointing to Tag id
296298
3. ``user_id``: Foreign key pointing to user_id (presumably the learner's id, although it appears that it is possible for staff to get grades as well) in ``auth_user`` table
297299
4. ``status_id``: Foreign key to ``CompetencyMasteryStatuses.id``. This table should have a constraint to only allow status values of “Demonstrated” and “PartiallyAttempted” since it represents overall competency demonstration state, not in-progress states.
298-
5. ``created``: The timestamp at which the student's competency status was set.
300+
5. ``created``: The timestamp at which the student's competency status row was first written.
301+
6. ``modified``: The timestamp at which the student's competency status was last updated.
299302

300303
7. Delete protection boundaries
301304

@@ -422,3 +425,11 @@ Rejected Alternatives
422425

423426
1. Silently does not work on this project's tested and production database backend. Django compiles a conditional ``UniqueConstraint`` to a partial index, which MySQL does not support; Django raises only a non-fatal system-check warning (``models.W036``) and skips creating the constraint, leaving the uniqueness rule completely unenforced at the database level.
424427
2. The gap would surface only as a data-integrity incident under concurrent writes, not as a test or migration failure, since SQLite (used for quick local test runs) does support partial indexes and would mask the problem in that environment.
428+
429+
Changelog
430+
---------
431+
432+
2026-07-27:
433+
434+
* Made the learner status indexes unique, so there is one row per learner and node. This is what
435+
the in-place, monotone status updates in :ref:`openedx-learning-adr-0004` read, lock, and update.

docs/openedx_learning/decisions/0003-competency-criteria-versioning.rst

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Typically, institutions and instructional designers do not change the mastery re
1313

1414
Currently, Open edX always displays the latest edited version of content in the Studio UI and always shows the latest published version of content in the LMS UI, despite having more robust version tracking on the backend (Publishable Entities).
1515

16-
Authoring data (criteria definitions) and runtime learner data (status) have different governance needs. The former is long-lived and typically non-PII, while the latter is user-specific, can be large (learners x criteria/competencies x time), and may require stricter retention and access controls. These differing lifecycles can make deep coupling of authoring and runtime data harder to manage at scale. Performance is also a consideration as computing or resolving versioned criteria for large courses could add overhead in Studio authoring screens or LMS views.
16+
Authoring data (criteria definitions) and runtime learner data (status) have different governance needs. The former is long-lived and typically non-PII, while the latter is user-specific, can be large (learners x criteria/competencies), and may require stricter retention and access controls. These differing lifecycles can make deep coupling of authoring and runtime data harder to manage at scale. Performance is also a consideration as computing or resolving versioned criteria for large courses could add overhead in Studio authoring screens or LMS views.
1717

1818
Decision
1919
--------
@@ -44,12 +44,13 @@ For the initial implementation, versioning and traceability of competency achiev
4444
- A ``CompetencyRuleProfile`` is "in use" if any ``CompetencyCriterion`` assigned to it (``competency_rule_profile_id``) has an associated ``StudentCompetencyCriteriaStatus`` row. Editing an in-use profile's ``rule_type``/``rule_payload`` requires the same warning and confirmation.
4545
- The same warning applies when creating a more specific profile causes existing criteria to be reassigned to it, and when an authoring action switches a criterion between a profile assignment and per-criterion overrides (ADR 0002 Decision 4).
4646

47-
5. Learner status models/tables are append-only history and do not use ``django-simple-history``:
47+
5. Do not store history for learner competency status tables, and update rows in place. These tables do not use ``django-simple-history``:
4848

49-
- For ``StudentCompetencyCriteriaStatus``, ``StudentCompetencyCriteriaGroupStatus``, and ``StudentCompetencyStatus``, each status change is stored as a new row with ``created`` as the write timestamp.
50-
- Existing learner status rows are not updated in place.
51-
- Current status is determined by the most recent row for a given learner + target entity (ordered by ``created``, with ``id`` as a tie-breaker).
52-
- Older rows represent the learner status history and remain available for audit/tracing.
49+
- For ``StudentCompetencyCriteriaStatus``, ``StudentCompetencyCriteriaGroupStatus``, and ``StudentCompetencyStatus``, each row is updated in place when a learner's status changes.
50+
- There is no history of prior status values beyond the ``modified`` timestamp, and no separate history table.
51+
- Current status is the single row for a given learner + target entity.
52+
53+
Open edX has no concept of gradeable subsection attempts. This means that an attempt is actually defined at the level of an individual problem, so storing one row per attempt can result in tens of billions of rows for a large Open edX instance. That scale creates real operational burden: schema migrations, backups, and truncation and retention policy. Therefore, we did additional market research and found that storing history for learner competency status will not be required by the initial pilot partners for the MVP of the CBE implementation, and it would be safe to add later if needed.
5354

5455

5556
Rejected Alternatives
@@ -85,3 +86,43 @@ Rejected Alternatives
8586
- Cons:
8687
- Requires custom tooling to reconstruct past versions
8788
- Does not align with existing publishable versioning patterns
89+
5. Keep the learner status tables append-only, storing every status change as a new row.
90+
- Pros:
91+
- Every write is an insert rather than a read-modify-write, so there is no current row to keep consistent.
92+
- Preserves a full audit trail of every status change.
93+
- Cons:
94+
- No MVP requirement calls for this history.
95+
- Grows the leaf table by a further multiplier of problem attempts per learner per leaf, reaching tens of billions of rows for a large instance.
96+
- A read must resolve the latest row for a learner and node rather than reading one in-place row, which is more expensive and more complex.
97+
6. Compute leaves transiently, never store them.
98+
- Pros:
99+
- Eliminates the largest table, since leaf demonstration would be computed on demand from the leaf's rule and the learner's grade.
100+
- Cons:
101+
- A recomputed leaf reflects the rule as it stands now, not the rule in force when the learner was graded, which contradicts Decision 4 above and can silently lower a status.
102+
7. Store child evaluations on the parent group row instead of a leaf table.
103+
- Pros:
104+
- Avoids the largest table entirely.
105+
- Cons:
106+
- A leaf write becomes a read-modify-write of a column shared with every sibling.
107+
- No unique index or foreign key stands behind a status packed into a per-group array, so nothing but application code keeps it consistent with the criteria it describes.
108+
- Couples a leaf's frozen mastery to the current shape of the criteria tree, so restructuring the tree can corrupt already-recorded mastery.
109+
- Removes the ability to individually track competency status progress by learning object, for example by subsection.
110+
8. Put the leaf table behind its own database alias and router, a separate physical database, or native partitioning or sharding, from the start.
111+
- Pros:
112+
- Physically isolates or splits the largest table from the start.
113+
- Cons:
114+
- A second database alias runs on its own connection, so the leaf write and the ancestor writes could no longer share a single transaction with the grade write, which gives up the atomicity these writes need.
115+
- Imposes real operational cost on every deployment with nothing measured to justify it, and remains available later if a specific need is proven.
116+
9. Serve heavy leaf-table reads from a read replica.
117+
- Pros:
118+
- Keeps dashboard and reporting reads off the primary.
119+
- Cons:
120+
- Premature: no measurement shows the primary struggling, and these are point lookups on a composite index, not the wide, expensive reads that drive ``StudentModule`` load in edx-platform.
121+
- Adding it later is a per-query choice, not a schema decision.
122+
10. Give the leaf table a custom unsigned 64-bit primary key.
123+
- Cons:
124+
- ``BigAutoField``'s range is already far out of reach for this table.
125+
- Unsigned integers do not exist in PostgreSQL, and the custom field type carries ongoing maintenance cost for no real benefit at this scale.
126+
11. Drop the database-level foreign key constraint on the learner column.
127+
- Cons:
128+
- This repo's convention is a real foreign key to ``settings.AUTH_USER_MODEL``. Reports of contention on the user row exist elsewhere in the ecosystem, but are not understood well enough here to design around.

0 commit comments

Comments
 (0)