Skip to content
Open
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f96a350
docs: competency ADR 4
jesperhodge Jul 10, 2026
847b789
docs: reject partition-key variant
jesperhodge Jul 13, 2026
a08bf5b
docs: fix RST indentation breaking readthedocs build
jesperhodge Jul 13, 2026
7c77aea
docs: make ADR 4 work without event bus
jesperhodge Jul 17, 2026
dc01a87
docs: finalize concurrency approach ADR
jesperhodge Jul 17, 2026
808a228
docs: concurrency and storage docs and diagrams
jesperhodge Jul 17, 2026
78bc16c
docs: add reasoning for rejecting per-learner batching
jesperhodge Jul 17, 2026
4cfb092
docs: choose monotonic non-locking option
jesperhodge Jul 20, 2026
c4354d5
docs: improve atomicity pattern
jesperhodge Jul 20, 2026
59c5467
docs: clean up ADR
jesperhodge Jul 20, 2026
385ccea
docs: clean up ADR 0002
jesperhodge Jul 21, 2026
cc44205
docs: delete superfluous diagrams
jesperhodge Jul 21, 2026
1eaebc7
docs: add text for optional read replica and avoidance of user_fk db …
jesperhodge Jul 22, 2026
9ac997a
docs: select grade-write transaction as entry
jesperhodge Jul 22, 2026
5a15bfe
docs: wrap competency status update in grade update transaction
jesperhodge Jul 22, 2026
a12d1a7
docs: clean up adr 4
jesperhodge Jul 22, 2026
250c5c8
docs: correct ADR 0004
jesperhodge Jul 23, 2026
ec37301
docs: clean up ADR 5
jesperhodge Jul 23, 2026
6e7cd7c
docs: reset ADRs 2 and 3 to state on main
jesperhodge Jul 23, 2026
c68f48f
docs: adjust ADRs 2 and 3 to align correctly
jesperhodge Jul 23, 2026
c49bedf
Merge branch 'main' into jesperhodge/competency-adr-4
jesperhodge Jul 23, 2026
f5bbba4
docs: address pr comments
jesperhodge Jul 24, 2026
b130e49
docs: address PR comments
jesperhodge Jul 27, 2026
b24aaa4
docs: add explanation for no foreign key to user
jesperhodge Jul 28, 2026
85526a4
docs: no routing or read replica for competency history table
jesperhodge Jul 30, 2026
3aec782
docs: remove unsigned big auto field
jesperhodge Jul 30, 2026
890d0f5
docs: only write advancing statuses to competency history
jesperhodge Jul 30, 2026
aedcf6c
docs: real foreign key to the user model for competency status
jesperhodge Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
.. _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, the platform must evaluate whether that grade
Comment thread
jesperhodge marked this conversation as resolved.
Outdated
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`, the group and competency levels are *materialized* (stored),
not recomputed on read, so that dashboards and other read surfaces stay fast. A single grade
change therefore has to recompute and re-write the derived rows from the changed leaf up to the
competency root. Per :ref:`openedx-learning-adr-0003`, every status table is append-only: a change
is a new row, and the current status is the most recent row for a learner and entity.

Two forces shape how this recording should happen:

- **Same-learner correctness.** Grade-change events arrive asynchronously and can be delivered out
of order and processed on more than one worker. Because writes are append-only, two evaluations
for the same learner that overlap can each read a stale snapshot of the sibling leaf statuses and
each append a derived roll-up computed from an incomplete picture (a write-skew). Leaf rows are
always correct, since each leaf is a pure function of its own grade; only the derived
group/competency rows can be left wrong. Nothing crashes and no constraint is violated, but a
learner's stored competency status can be silently incorrect.

@ormsbee ormsbee Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is a silly question, but can't we mitigate this by making sure to commit the transaction of the leaf statuses, and then re-read the latest commited state of the leaves before doing the roll-up?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing we can do (if it's necessary) is to arrange it so that the roll-ups are a log that have an incrementing version number, and create a composite unique index that would cause a conflict that would reject concurrent writes. The losing write would have to catch the error, increment the version number again, and re-read the database state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this question is not relevant anymore with the current version, right? Since now we only process on one worker at a time.


- **Throughput.** Grading is bursty and spans a very large number of learners, so the recording
path must keep up without paying a serialization or per-event transaction cost that grows with
the number of learners being graded.

The question is how to guarantee same-learner correctness while still recording at high aggregate
throughput.

Decision
--------
Grade-change events are consumed from the openedx-events event bus, processed in windowed
micro-batches, and serialized by a single deployment-wide batch lock, so a learner's competency
mastery is always recorded correctly. Correctness is chosen over horizontal write throughput:
a single serialized pipeline is adequate while it keeps up with peak grading, and it keeps the
recorder self-contained and behaving identically on any Open edX deployment.

**The recording pipeline.** Resolving which competencies a subsection feeds is learner-independent,
so it is cached and de-duplicated across the batch. Each batch does one bulk read of current
statuses, evaluates in memory, and does one bulk append, collapsing per-event transaction overhead
into a small, fixed number of round-trips. The in-memory engine (:ref:`openedx-learning-adr-0002`)
is unchanged: to apply several of one learner's events, the recorder folds them in edited-timestamp
order against an evolving snapshot. Persistence stays append-only (:ref:`openedx-learning-adr-0003`)
and writes a new row only when the computed status differs from the current one; an older,
out-of-order grade is ignored by comparing its source edited timestamp against the current leaf's,
so a late arrival cannot regress a newer status.

**Correctness by a single serializing batch lock.** One deployment-wide lock guards the whole batch
operation, so only one batch runs at a time across the deployment. The read, evaluation, and append
for a batch all happen under that lock, so no two workers ever evaluate the same learner
concurrently and the same-learner write-skew described in the Context cannot occur. The lock is
realized on infrastructure every deployment already has (for example, a database-backed advisory
lock) rather than on the event transport, so the recorder behaves the same on any event-bus backend
and adds no new operational dependency.

Accepted tradeoffs:

- The write path is a single pipeline: only one batch runs at a time, so recording and evaluating does not
scale horizontally. This is adequate only while one batch pipeline keeps up with peak grading;
sustained high-volume growth would force a revisit.
- It introduces a lock and its lifecycle (acquisition, timeout, stale-lock recovery on crash)
that a lock-free partitioning design would avoid.

Rejected Alternatives
---------------------

1. Correctness by keyed partitioning instead of a lock.

Partition grade-change events by ``user_id``: the key is *hashed* onto a small, fixed number of
partitions (not one partition per learner), so every event for a learner lands on the same
partition and is consumed by exactly one consumer. Same-learner events are then processed serially
by construction, with no database lock, while different learners are processed in parallel across
partitions.

- Pros:
- Correctness is structural; no lock and no lock lifecycle.
- The write path scales horizontally with the partition count.
- Cons:
- Relies on the transport routing by key. Kafka does this natively; Redis Streams consumer
groups do not, so on Redis it needs application-level sharding into per-shard streams.
- Couples openedx-core to a platform-side contract in the wrong direction: the producer, in
openedx-platform, must set the partition key and keep the partition count stable, so the
core recorder's correctness depends on platform-side behavior.
- Changing the partition count reshuffles learners and can briefly let two consumers touch
one learner, so it needs an on-demand reconciliation command as a backstop.
- Why rejected: it optimizes for horizontal throughput at the cost of the two properties this
decision values most. It depends on the event transport (Kafka's keyed routing, or Redis-side
sharding that is not an established pattern in vanilla Open edX), so it would not behave
uniformly on a stock deployment, whereas the chosen approach is transport-agnostic. And it
inverts the intended dependency direction by making openedx-core rely on an openedx-platform
partition-key contract plus a reconciliation backstop. The horizontal scale it buys is not
needed while a single batch pipeline keeps up with peak grading, and accuracy is the priority
over the extra latency the single pipeline adds.

2. Shrink the batch lock to guard only the bulk read and bulk write, running the per-learner
evaluation in parallel outside the lock.

- Motivation: the database I/O is cheap (a few bulk statements), so locking only the I/O and
parallelizing the heavier evaluation looks like it would keep correctness while lifting the
chosen approach's single-pipeline cap.
- Why rejected: the lock exists to make each learner's read-evaluate-write atomic against other
writers, not to protect the I/O. Moving evaluation outside the lock reintroduces the exact
write-skew from the Context: two workers read the same snapshot for one learner, both
evaluate, and both append a roll-up from an incomplete picture. Making parallel evaluation
correct requires that each learner is only ever handled by one worker at a time, which is
per-learner isolation, i.e. the keyed-partitioning alternative above. This is therefore not a
distinct option: with the isolation added it becomes keyed partitioning; without it, it is
incorrect.

3. Per-learner database row lock with per-event recording.
- Pros:
- Correct regardless of delivery order or deployment topology, without depending on how
events are routed or partitioned.
- Conceptually simple and self-contained: it relies only on the database, with no
event-bus partitioning contract.
- Cons:
- Processes one event at a time behind a lock and a transaction, so it does not keep up
under bursty grading across many learners.
- Holds a database connection and a worker for each event while the lock is contended or
waited on.
- Requires an extra per-learner lock table and its locking machinery.
- Does not batch writes, so per-event transaction and commit overhead dominates at volume.

This was an earlier design for competency mastery recording; it is correct but the least
performant. Both the chosen batch-lock approach and the keyed-partitioning alternative
supersede it: keyed partitioning provides the same same-learner serialization as a structural
property while allowing parallelism and batching, and the batch lock provides it with a single
lock and batched writes instead of a per-learner lock and per-event writes.

4. No lock; assume same-learner conflicts are rare and tolerate them.
- Pros:
- The least machinery of any option: no lock, no partitioning-for-correctness, relying on
append-only self-healing and a reconciliation job.
- Cons:
- Correctness becomes best-effort. A concurrent same-learner conflict can leave a transient
wrong derived status.
- A wrong status lingers indefinitely if the learner receives no further relevant event.
- Unacceptable where mastery feeds credentialing and learner- or instructor-facing
dashboards, in which an incorrect status is directly visible.

5. Recompute derived levels on read instead of materializing them.
- Pros:
- Removes the write-skew hazard entirely: if nothing derived is stored, nothing derived can
drift, and the write path is trivial.
- Cons:
- Reopens :ref:`openedx-learning-adr-0002`, which deliberately materializes derived levels
for dashboard read performance.
- Moves the cost onto every read, which is the surface that decision was protecting.
- Out of scope for this decision.