Follow-up to #128 (finding #9), deferred deliberately in PR #242.
Problem
PR #242 consolidated every mastery write onto a single path (graph_service.apply_mastery_event), but that write is still a non-atomic read-modify-write:
- READ
mastery_score / mastery_events from graph_nodes - MODIFY in Python (clamp, append capped event)
- WRITE the whole value back
Two concurrent writers to the same node (e.g. a quiz submission and an agent mastery tool, or two quick submissions) both read the old value and the second write clobbers the first — a classic lost-update race. mastery_events entries and score deltas can silently disappear.
Location
backend/services/graph_service.py → apply_mastery_event (the inline NOTE (#9, deferred) marks the exact spot).
Why it was deferred
The real fix is a DB-side atomic append (Postgres RPC / SQL UPDATE ... SET mastery_score = ..., mastery_events = mastery_events || ...), which needs the migration-runner plumbing tracked in #195 / #197. Now that all mastery writes share one path, this becomes a single-place change.
Fix options
- (preferred) Postgres RPC performing the clamp + append atomically server-side.
- Optimistic concurrency: a
version column, reject/retry on stale write. - Single-writer path or row-level lock.
Refs: #128 (finding #9), #195, #197, epic #136.
🤖 Filed via Claude Code
Follow-up to #128 (finding #9), deferred deliberately in PR #242.
Problem
PR #242 consolidated every mastery write onto a single path (
graph_service.apply_mastery_event), but that write is still a non-atomic read-modify-write:mastery_score/mastery_eventsfromgraph_nodesTwo concurrent writers to the same node (e.g. a quiz submission and an agent mastery tool, or two quick submissions) both read the old value and the second write clobbers the first — a classic lost-update race.
mastery_eventsentries and score deltas can silently disappear.Location
backend/services/graph_service.py→apply_mastery_event(the inlineNOTE (#9, deferred)marks the exact spot).Why it was deferred
The real fix is a DB-side atomic append (Postgres RPC / SQL
UPDATE ... SET mastery_score = ..., mastery_events = mastery_events || ...), which needs the migration-runner plumbing tracked in #195 / #197. Now that all mastery writes share one path, this becomes a single-place change.Fix options
versioncolumn, reject/retry on stale write.Refs: #128 (finding #9), #195, #197, epic #136.
🤖 Filed via Claude Code