|
| 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