-
Notifications
You must be signed in to change notification settings - Fork 26
Competency Mastery Concurrency ADR #657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f96a350
847b789
a08bf5b
7c77aea
dc01a87
808a228
78bc16c
4cfb092
c4354d5
59c5467
385ccea
cc44205
1eaebc7
9ac997a
5a15bfe
a12d1a7
250c5c8
ec37301
6e7cd7c
c68f48f
c49bedf
f5bbba4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| .. _openedx-learning-adr-0004: | ||
|
|
||
| 4. How should learner competency mastery be recorded concurrently and at scale? | ||
| ================================================================================ | ||
|
|
||
| Status | ||
| ------ | ||
| Proposed. | ||
|
|
||
| Context | ||
| ------- | ||
| When a learner is graded on a subsection (or any other learning instrument associated to a competency | ||
| with a competency criteria, like a course or rubric criterion), the platform must evaluate whether that grade | ||
| demonstrates any attached competencies and record the learner's mastery. Mastery is recorded at | ||
| three levels: the criterion (leaf), the criteria group, and the competency. Per | ||
| :ref:`openedx-learning-adr-0002` and :ref:`openedx-learning-adr-0005`, all three levels are | ||
| *materialized* (stored), not recomputed on read, so that dashboards and other read surfaces stay | ||
| fast. A single grade change therefore writes the changed leaf's status and then re-evaluates and | ||
| re-writes the derived rows from that leaf up to the competency root. The re-evaluation | ||
| is needed for multiple reasons, including notifications, and badge and certificate issuing. Per | ||
| :ref:`openedx-learning-adr-0005`, each level is stored as an ACTIVE row updated in place, holding | ||
| the current status for a learner and node, plus an append-only HISTORY row per genuine status | ||
| advance. | ||
|
|
||
| **Monotonicity: competency statuses only ever move forward.** Per | ||
| :ref:`openedx-learning-adr-0005`, every node, at every level, advances through a small status | ||
| lattice (``AttemptedNotDemonstrated`` to ``PartiallyAttempted`` to ``Demonstrated``) and is never | ||
| lowered later. This holds for leaf nodes, group nodes, and top-level competency masteries. | ||
|
|
||
| Two forces shape how recording should happen: | ||
|
|
||
| - **Same-learner correctness.** A grade change writes the changed leaf and then re-derives the | ||
| group and competency rows above it. Leaf rows are always correct, since each leaf is a pure | ||
| function of its own grade. The derived rows are the hazard: We want to avoid a case where two evaluations for the same learner | ||
| that overlap can each read a stale snapshot of the sibling leaf statuses and each write a derived | ||
| roll-up computed from an incomplete picture (a *write-skew*). | ||
|
|
||
| - **Throughput.** Grading is bursty and spans a very large number of learners, so the recording | ||
| path must keep up under peak load. | ||
|
|
||
| Decision | ||
| -------- | ||
|
|
||
| **1. Every write is a monotone merge, never a blind overwrite.** A node's status is written as | ||
| ``status := max(stored status, newly computed status)`` (a single ``GREATEST``-style ``UPDATE``, | ||
| atomic at the row for the duration of that one statement, with no application-level lock). Because | ||
| the merge takes the higher of the two values, it is commutative, idempotent, and insensitive to | ||
| order. This is why out-of-order delivery and re-delivery are harmless without sequence tracking. | ||
|
|
||
| **2. When a child advances, its parent is recomputed in the same transaction, under a brief row lock on that parent.** | ||
| The merge in mechanism 1 makes a single-row write safe, but a *conjunctive* | ||
| parent (for example "demonstrated only when all children are demonstrated") is computed by reading | ||
| several child rows first, so two overlapping evaluations for one learner could each read a stale | ||
| sibling and compute a parent that is too low. To prevent that, recomputing a parent takes a | ||
| row-level lock on the parent row (a ``SELECT ... FOR UPDATE``) before reading its children: two | ||
| updates that touch the same parent for the same learner take turns, and the second reads the first's | ||
| committed children and computes from the complete picture. Locks are taken child-before-parent up | ||
| the path to the root, a consistent order, so concurrent updates cannot deadlock. This is an ordinary | ||
| single-row lock. Every read the recorder makes, here and in the mass-recompute path | ||
| (:ref:`openedx-learning-adr-0005`), runs against the primary database and never a read replica: | ||
| these reads feed the roll-up write and take the row locks above, so a replica's lag would compute a | ||
| roll-up from stale siblings. The read-replica offload in :ref:`openedx-learning-adr-0005` is | ||
| reserved for the read-only dashboard and reporting paths. | ||
|
|
||
| **3. Entry point: edx-platform subsection grade change.** edx-platform | ||
| computes subsection grades in an async celery task (`recalculate_subsection_grade_v3`) triggered by a score-change signal, not on the | ||
| request thread. After that task writes the subsection grade, it calls a public openedx-core function | ||
| 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. | ||
|
|
||
| **4. ACTIVE writes and roll-ups commit atomically with the grade, or the whole task rolls back and retries.** | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Either it's not clear to me what this is trying to say or it contradicts point 3 above it. I think my confusion might stem from the phrase "with the grade" because that is implying to me that writes to our competency status tables roll up atomically with the subsection grade table. But I think this is trying to say that writes for the competency criteria hierarchy status tables commit atomically with each other. Am I confused or does this need to be reworded for clarity, or both?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nevermind, I reread point 3, and I see that they're not contradictory now. It's possible that this could still be made clearer though since I did have to reread to understand. |
||
|
|
||
| **5. The leaf HISTORY append runs outside the transaction, best effort.** Because the leaf HISTORY table | ||
| (``StudentCompetencyCriteriaStatusHistory``) may be routed to a | ||
| separate physical database (:ref:`openedx-learning-adr-0005`), its append runs after that transaction commits, as a best-effort, | ||
| non-blocking write. | ||
|
|
||
|
|
||
| Rejected Alternatives | ||
| --------------------- | ||
|
|
||
| 1. Prevent concurrent writes with a deployment-wide lock. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be nice if the Rejected Alternatives each had brief Pros and Cons.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or a rationale sentence or two like in ADR 5 below. |
||
|
|
||
| Once every write is a monotone merge and each | ||
| parent recompute is serialized only against concurrent writers of that same node by a brief row lock, correctness holds without needing a larger lock. | ||
|
|
||
| 4. Recompute derived levels on read instead of materializing them. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This numbering is off |
||
|
|
||
| Settled in :ref:`openedx-learning-adr-0002`. | ||
|
|
||
| 8. Send an event to openedx-core and update competency statuses in a separate celery task. | ||
|
|
||
| We want to avoid data drift. Wrapping the competency status update in the grade calculation transaction on edx-platform ensures atomicity. | ||
Uh oh!
There was an error while loading. Please reload this page.