Skip to content

Commit 01933ee

Browse files
jesperhodgeclaude
andcommitted
docs: add ADR for competency mastery concurrency
Adds ADR 0004 covering how learner competency mastery is recorded under concurrent, out-of-order grade-change events without a per-event serialization cost. Adjusts ADRs 0002 and 0003 to match: learner status is stored as an in-place ACTIVE row plus a paired append-only HISTORY table, with a unique index on the leaf HISTORY advance that serves as the idempotency key for the append. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 845dc1f commit 01933ee

3 files changed

Lines changed: 187 additions & 10 deletions

File tree

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,14 @@ 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)``
246-
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)
247-
10. ``CompetencyMasteryStatuses(status)`` (unique)
243+
6. ``StudentCompetencyCriteriaStatus(user_id, competency_criteria_id)`` (unique)
244+
7. ``StudentCompetencyCriteriaStatusHistory(user_id, competency_criteria_id, status_id)`` (unique -- at most one HISTORY row per learner, leaf, and status level, which also serves as the idempotency key for the append in :ref:`openedx-learning-adr-0004`)
245+
8. ``StudentCompetencyCriteriaGroupStatus(user_id, competency_criteria_group_id)`` (unique)
246+
9. ``StudentCompetencyCriteriaGroupStatusHistory(user_id, competency_criteria_group_id)``
247+
10. ``StudentCompetencyStatus(user_id, oel_tagging_tag_id)`` (unique)
248+
11. ``StudentCompetencyStatusHistory(user_id, oel_tagging_tag_id)``
249+
12. ``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)
250+
13. ``CompetencyMasteryStatuses(status)`` (unique)
248251

249252
6. Learner progress status concepts (``StudentCompetency*Status`` database tables)
250253

@@ -257,6 +260,12 @@ Decision
257260
- ``StudentCompetencyStatus`` tracks top-level competency demonstration state.
258261
- All learner status rows use a shared lookup table (``CompetencyMasteryStatuses``) so status semantics live in one place and student status tables stay structurally consistent.
259262

263+
Append-only history tables:
264+
265+
- ``StudentCompetencyCriteriaStatusHistory``
266+
- ``StudentCompetencyCriteriaGroupStatusHistory``
267+
- ``StudentCompetencyStatusHistory``
268+
260269
Intended update flow (bottom-up materialization):
261270

262271
- A learner event updates one ``StudentCompetencyCriteriaStatus`` row.
@@ -422,3 +431,15 @@ Rejected Alternatives
422431

423432
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.
424433
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.
434+
435+
Changelog
436+
---------
437+
438+
2026-07-27:
439+
440+
* Split learner status storage into paired ACTIVE and HISTORY tables: added the append-only
441+
``StudentCompetencyCriteriaStatusHistory``, ``StudentCompetencyCriteriaGroupStatusHistory``,
442+
and ``StudentCompetencyStatusHistory`` tables and their indexes alongside the in-place ACTIVE
443+
tables, per :ref:`openedx-learning-adr-0005`.
444+
* Made the leaf HISTORY (``StudentCompetencyCriteriaStatusHistory``) index unique on ``(user_id, competency_criteria_id, status_id)``, the
445+
idempotency key for the HISTORY append in :ref:`openedx-learning-adr-0004`.

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

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,21 @@ 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. Learner status models/tables are updated in-place:
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``,
50+
each status change updates the responsible row.
51+
- Statuses only increase monotonically as described by :ref:`openedx-learning-adr-0005`;
52+
downward status adjustments (for example ``Demonstrated`` to ``PartiallyAttempted``) are prohibited.
53+
54+
6. Learner status models/tables as in 5. above each get a separate append-only history table not using ``django-simple-history``:
55+
56+
- For ``StudentCompetencyCriteriaStatusHistory``, ``StudentCompetencyCriteriaGroupStatusHistory``, and ``StudentCompetencyStatusHistory``,
57+
each status advance is stored as a new row with ``created`` as the write timestamp.
58+
- Existing learner status rows are not updated in place in the history tables.
59+
- Statuses only increase monotonically as described by :ref:`openedx-learning-adr-0005`;
60+
if a change would mean a downward adjustment (for example ``Demonstrated`` to ``PartiallyAttempted``)
61+
or no adjustment, this does not get stored in the history tables.
5362

5463

5564
Rejected Alternatives
@@ -85,3 +94,13 @@ Rejected Alternatives
8594
- Cons:
8695
- Requires custom tooling to reconstruct past versions
8796
- Does not align with existing publishable versioning patterns
97+
98+
Changelog
99+
---------
100+
101+
2026-07-27:
102+
103+
* Reworked learner status handling to match :ref:`openedx-learning-adr-0005` and
104+
:ref:`openedx-learning-adr-0004`: Decision 5 now updates learner status rows in place and
105+
monotonically (downward adjustments prohibited), and a new Decision 6 adds separate append-only
106+
HISTORY tables. Previously a single append-only model with no in-place ACTIVE row.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
.. _openedx-learning-adr-0004:
2+
3+
4. How should learner competency mastery be recorded concurrently and at scale?
4+
================================================================================
5+
6+
Status
7+
------
8+
Proposed.
9+
10+
Context
11+
-------
12+
When a learner is graded on a subsection (or any other learning instrument associated to a competency
13+
with a competency criteria, like a course or rubric criterion), the platform must evaluate whether that grade
14+
demonstrates any attached competencies and record the learner's mastery. Mastery is recorded at
15+
three levels: the criterion (leaf), the criteria group, and the competency. Per
16+
:ref:`openedx-learning-adr-0002` and :ref:`openedx-learning-adr-0005`, all three levels are
17+
*materialized* (stored), not recomputed on read, so that dashboards and other read surfaces stay
18+
fast. A single grade change therefore writes the changed leaf's status and then re-evaluates and
19+
re-writes the derived rows from that leaf up to the competency root. The re-evaluation
20+
is needed for multiple reasons, including notifications, and badge and certificate issuing. Per
21+
:ref:`openedx-learning-adr-0005`, each level is stored as an ACTIVE row updated in place, holding
22+
the current status for a learner and node, plus an append-only HISTORY row per genuine status
23+
advance.
24+
25+
**Monotonicity: competency statuses only ever move forward.** Per
26+
:ref:`openedx-learning-adr-0005`, every node, at every level, advances through a small status
27+
lattice (``AttemptedNotDemonstrated`` to ``PartiallyAttempted`` to ``Demonstrated``) and is never
28+
lowered later. This holds for leaf nodes, group nodes, and top-level competency masteries.
29+
30+
Two forces shape how recording should happen:
31+
32+
- **Same-learner correctness.** A grade change writes the changed leaf and then re-derives the
33+
group and competency rows above it. Leaf rows are always correct, since each leaf is a pure
34+
function of its own grade. The derived rows are the hazard: We want to avoid a case where two evaluations for the same learner
35+
that overlap can each read a stale snapshot of the sibling leaf statuses and each write a derived
36+
roll-up computed from an incomplete picture (a *write-skew*).
37+
38+
- **Throughput.** Grading is bursty and spans a very large number of learners, so the recording
39+
path must keep up under peak load.
40+
41+
Decision
42+
--------
43+
44+
**1. Every write is a monotone merge, never a blind overwrite.** A node's status is written as
45+
``status := max(stored status, newly computed status)`` (a single ``GREATEST``-style ``UPDATE``,
46+
atomic at the row for the duration of that one statement, with no application-level lock). Because
47+
the merge takes the higher of the two values, it is commutative, idempotent, and insensitive to
48+
order. This is why out-of-order delivery and re-delivery are harmless without sequence tracking.
49+
50+
**2. When a child advances, its parent is recomputed in the same transaction, under a brief row lock on that parent.**
51+
The merge in mechanism 1 makes a single-row write safe, but a *conjunctive*
52+
parent (for example "demonstrated only when all children are demonstrated") is computed by reading
53+
several child rows first, so two overlapping evaluations for one learner could each read a stale
54+
sibling and compute a parent that is too low. To prevent that, recomputing a parent takes a
55+
row-level lock on the parent row (a ``SELECT ... FOR UPDATE``) before reading its children: two
56+
updates that touch the same parent for the same learner take turns, and the second reads the first's
57+
committed children and computes from the complete picture. This correctness argument assumes
58+
``READ COMMITTED`` isolation (the Open edX platform default on MySQL; higher isolation levels are not
59+
supported on the platform): under it the lock's own read and the sibling reads that follow it always
60+
return the latest committed rows, rather than a snapshot fixed at an earlier read in the same
61+
transaction, which is what a higher level such as ``REPEATABLE READ`` would do. Locks are taken child-before-parent up
62+
the path to the root, a consistent order, so concurrent updates cannot deadlock. This is an ordinary
63+
single-row lock.
64+
65+
**3. Entry point: edx-platform subsection grade change.** edx-platform
66+
computes subsection grades in an async celery task (`recalculate_subsection_grade_v3`) triggered by a score-change signal, not on the
67+
request thread. After that task writes the subsection grade, it calls a public openedx-core function
68+
within the same transaction; this function does the monotone merge and the upward roll-up. This should be generalized as needed to other places that trigger a competency status update.
69+
70+
**4. The ACTIVE writes, the HISTORY appends, and the roll-ups all commit atomically with the
71+
subsection grade.** The leaf, group, and competency ACTIVE writes from mechanisms 1 and 2, and the
72+
HISTORY row appended for each genuine advance, run inside the same transaction that mechanism 3
73+
opened for the subsection-grade write, so they commit as a single unit with it. If any step fails, that transaction rolls back and the task retries, leaving
74+
behind neither a partial roll-up nor an ACTIVE status whose advance went unrecorded. A unique
75+
constraint on the advance (learner, node, and status; :ref:`openedx-learning-adr-0002`) makes the
76+
append idempotent, so a retried task or a redelivered grade event collapses to a no-op rather than
77+
writing a duplicate row.
78+
79+
**5. Only an advance is appended to HISTORY.** The monotone merge in mechanism 1 often leaves a status
80+
where it was, because the newly computed status equals or is lower than the stored one. Those writes
81+
append nothing: a redelivered grade event, a downward grade correction, and a recompute that confirms
82+
the current status all leave HISTORY untouched. So the recorder writes at most one HISTORY row per
83+
learner, node, and step up the lattice, which is what bounds HISTORY to the same order of magnitude as
84+
ACTIVE rather than to grading volume (:ref:`openedx-learning-adr-0005`).
85+
86+
87+
Rejected Alternatives
88+
---------------------
89+
90+
1. Prevent concurrent writes with a coarser lock, either deployment-wide or per-learner.
91+
92+
- Pros:
93+
- Correctness comes from a single lock rather than from the monotone-merge argument, so it is
94+
simpler to reason about.
95+
- A per-learner lock (for example a database advisory lock keyed on a hash of the user id)
96+
still lets different learners record in parallel, and gives the same per-learner
97+
serialization the chosen design relies on.
98+
- Cons:
99+
- A single deployment-wide lock serializes recording across every learner, giving up the
100+
throughput the design needs under bursty grading.
101+
- A per-learner lock still serializes a single learner's independent competencies against each
102+
other even when they never contend.
103+
- Either lock adds lock-lifecycle machinery (acquisition, release, and handling a holder that
104+
dies) across a very large key space.
105+
- The chosen design needs no such lock: the monotone merge (mechanism 1) makes each single-row
106+
write safe, and the brief per-parent row lock (mechanism 2) serializes only writers that
107+
actually contend for the same parent row of the same learner, so different learners, and
108+
different competencies of one learner, still record in parallel.
109+
110+
2. Recompute derived levels on read instead of materializing them.
111+
112+
- Pros:
113+
- Eliminates the derived group and competency status rows and the roll-up writes entirely,
114+
leaving nothing to keep consistent on write.
115+
- Cons:
116+
- Moves the full bottom-up tree evaluation onto the hot read path, the opposite of what
117+
dashboards and other read surfaces need (a direct indexed lookup).
118+
- Settled against in :ref:`openedx-learning-adr-0002`.
119+
120+
3. Send an event to openedx-core and update competency statuses in a separate celery task.
121+
122+
- Pros:
123+
- Decouples the mastery update from the grade write, so grade recording does not depend on
124+
competency code being installed or fast.
125+
- Cons:
126+
- Without a shared transaction, a failure or a lost event leaves the grade and its mastery rows
127+
permanently out of sync (data drift), with no way to roll them back together.
128+
- Recording the ACTIVE writes in the same transaction as the grade (mechanism 3) instead makes
129+
the grade and its mastery consequences commit or fail as a unit.
130+
131+
4. Append the leaf HISTORY row outside the grade transaction, as a retrying task dispatched with
132+
``transaction.on_commit``.
133+
134+
This would be mandatory if the HISTORY table were ever
135+
routed to a separate database alias, since a write on another connection cannot be atomic
136+
with the primary transaction. Since we decided that every status table lives in the main database
137+
(:ref:`openedx-learning-adr-0005`), this is unnecessary.

0 commit comments

Comments
 (0)