Skip to content

Fix SR exercise-outcome crashes + move translation-validation off the hot path#679

Open
mircealungu wants to merge 3 commits into
masterfrom
wt/sr-outcome-fix
Open

Fix SR exercise-outcome crashes + move translation-validation off the hot path#679
mircealungu wants to merge 3 commits into
masterfrom
wt/sr-outcome-fix

Conversation

@mircealungu

Copy link
Copy Markdown
Member

Investigates and fixes the 6 new error signatures in the word-scheduling (SR) path from the 2026-07-15 afternoon log digest (zapi / api.zeeguu.org), then removes the root cause architecturally.

The crashes (all in /report_exercise_outcome)

  1. InvalidRequestError: Instance '<UserWord>' has been deleted (~4×) — the lazy LLM translation-validation inside the scheduler re-homed the practiced word to a corrected meaning and deleted the original UserWord mid-request; the Exercise was then bound to the deleted object.
  2. IntegrityError: Duplicate entry for key 'unique_user_word_schedule' (~3×) — two concurrent reports both passed the "no schedule yet" check and inserted; the loser's commit hit the unique constraint.
  3. AttributeError: 'NoneType' object has no attribute 'update_schedule' (1×) — find_or_create returned None (word judged unfit/duplicate) and update() dereferenced it.

Fixes

Immediate guards (commit 1):

  • report_exercise_outcome logs the Exercise against the UserWord scheduler.update() returns (the survivor), never a deleted self.
  • find_or_create catches the duplicate-insert IntegrityError, rolls back, and returns the winning row.
  • update() no longer dereferences a None schedule.

Model constraint (commit 2): declared unique_user_word_schedule in __table_args__ so the invariant lives next to the column and the test DB enforces it — the duplicate-race test now exercises the real constraint.

Root-cause removal — validation off the hot path (commit 3): the deeper smell was a synchronous LLM call sitting in the exercise-report path that could delete the practiced word.

  • find_or_create is now a fast, non-destructive "ensure a schedule row exists" primitive — no LLM, no re-home.
  • Validation runs asynchronously right after scheduling (run_in_background), but only when the word actually got scheduled (learning pipeline not full); a full pipeline leaves it for the night.
  • Nightly tools/validate_scheduled_meanings.py is the backstop (added to the ops crontab — separate ops change).
  • The async worker is idempotent, re-queries by id, re-homes via the existing validate_and_fix, and unschedules words that turn out unfit/duplicate.

Tests

zeeguu/core/test/test_scheduling.py — 12 passing, covering all three crash regressions plus the new async behavior (no inline validation; worker no-op when valid / unschedules unfit; trigger fires only for a scheduled, not-yet-valid word).

Follow-up (not in this PR)

  • ops: nightly validate_scheduled_meanings crontab line (needs commit + crontab deploy).
  • docs: design note in zeeguu-docs/future-work/translation-validation-off-exercise-hot-path.md.

🤖 Generated with Claude Code

mircealungu and others added 3 commits July 15, 2026 21:59
Reporting an exercise outcome could crash in three ways, all in the
word-scheduling path behind /report_exercise_outcome (the broad except in
the endpoint turned each into a "FAIL" + logged traceback, silently
dropping the user's exercise result):

1. "Instance UserWord has been deleted" — report_exercise_outcome built the
   Exercise bound to `self`, then ran the scheduler, whose lazy
   translation-validation (find_or_create -> validate_and_fix -> _fix_bookmark
   -> cleanup_old_user_word) can move the word to a corrected meaning and
   delete the original UserWord. The Exercise (and caller) were left pointing
   at a deleted instance. Now the scheduler runs first and returns the
   surviving UserWord; the Exercise is logged against that.

2. Duplicate 'unique_user_word_schedule' IntegrityError — two concurrent
   reports for the same new word both passed the "no schedule yet" check and
   inserted. FourLevelsPerWord.find_or_create now catches the IntegrityError,
   rolls back, and returns the row the winning request created.

3. "'NoneType' object has no attribute 'update_schedule'" — find_or_create
   returns None when the word is judged unfit (invalid / duplicate /
   unvalidatable translation); BasicSRSchedule.update dereferenced it. Now
   guarded.

BasicSRSchedule.update returns the scheduled UserWord so callers never hold a
stale/deleted reference. Adds three regression tests that each fail on the
pre-fix code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The uniqueness of basic_sr_schedule.user_word_id lived only in the migration
SQL, so the SQLite test DB never enforced it. Declaring it in __table_args__
documents the invariant next to the column and lets the test DB enforce it.

With the constraint now live in tests, the duplicate-schedule-race regression
test exercises the *real* IntegrityError instead of a mocked one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scheduling a word used to run a synchronous LLM translation-validation inside
FourLevelsPerWord.find_or_create, on the first exercise of an unvalidated word.
That put an LLM round-trip on /report_exercise_outcome and — when the validator
re-homed the word to a corrected meaning — deleted the practiced UserWord
mid-request (the "Instance UserWord has been deleted" crash).

Validation now happens off the hot path:

- find_or_create is a fast, non-destructive primitive: it ensures a schedule
  row exists and never calls the LLM. meaning.validated != VALID is the
  "still needs validation" marker.

- UserWord.report_exercise_outcome fires
  UserWordValidationService.validate_scheduled_user_word via run_in_background,
  but only when the word actually got scheduled (pipeline not full) and isn't
  VALID yet. A full pipeline leaves the word unscheduled and unvalidated — the
  nightly batch handles it if it's ever scheduled. Guarded off under TESTING /
  no app context so it never spawns threads in the test DB.

- The worker is idempotent, re-queries by id (thread-safe), re-homes via the
  existing validate_and_fix, and clears the schedule if the word turns out
  unfit/duplicate so a known-bad word leaves the rotation.

Backstop: tools/validate_scheduled_meanings.py is added to the ops crontab
nightly (separate ops change) to catch anything the async path missed.

Adds tests: find_or_create no longer validates inline; the worker is a no-op
when already valid and unschedules unfit words; the trigger fires only for a
scheduled, not-yet-valid word.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ArchLens detected architectural changes in the following views:
diff
diff

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant