fix: mark an answer failed when its last grading write fails

WR-07: when storing the grader's score fails on the last River attempt,
record the answer as failed instead of leaving it pending with no job.
The terminal failure write runs on a context detached from the job's,
so a timed-out or cancelled job still records it.
This commit is contained in:
Jakub Zych
2026-11-09 22:53:17 +01:00
parent 9ac53579e4
commit 898de758ba

View File

@@ -189,11 +189,12 @@ func fireAfterCommit[T any](ctx context.Context, s *Service, tx *gorm.DB, name s
// A missing answer, one that is no longer pending (graded, failed or
// reviewed by the owner), one whose grading_seq differs from seq (the
// student wrote a newer text) and one whose question is no longer open are
// left alone, as is an answer whose quiz has no registered grader. Each run counts an attempt for the current
// text first. A grader error, or a score ClampOpenPoints refuses, is
// returned for River to retry while fewer than GradeMaxAttempts attempts
// were made; the last one marks the answer failed with the error and
// returns nil (D-17). A score is stored as graded with points clamped to
// left alone, as is an answer whose quiz has no registered grader. Each
// run counts an attempt for the current text first. A grader error, or a
// score ClampOpenPoints refuses, is returned for River to retry while
// fewer than GradeMaxAttempts attempts were made; the last one marks the
// answer failed with the error and returns nil (D-17), as does an error
// storing the score on the last attempt. A score is stored as graded with points clamped to
// 0..question points in steps of 0.5, the attempt's score is recalculated
// when it is completed, and AnswerGradedEvent fires after the commit.
func (s *Service) GradeAnswer(ctx context.Context, answerID uint, seq int) error {
@@ -266,9 +267,32 @@ WHERE id = ? AND grading_status = ? AND grading_seq = ? RETURNING grading_attemp
case GradingRetry:
return gradeErr
case GradingFail:
return s.failGrading(ctx, row.ID, attempt.ID, seq, grader.Code(), gradeErr)
return s.failLast(ctx, row.ID, attempt.ID, seq, grader.Code(), gradeErr)
case GradingStore:
return s.storeGrade(ctx, row.ID, seq, &attempt, question, grader.Code(), points, res.Feedback)
err := s.storeGrade(ctx, row.ID, seq, &attempt, question, grader.Code(), points, res.Feedback)
if err != nil && attempts >= GradeMaxAttempts {
// River will not run the job again: without this the answer
// would stay pending with no job (D-17).
return s.failLast(ctx, row.ID, attempt.ID, seq, grader.Code(), err)
}
return err
}
return nil
}
// failLastTimeout bounds the write that marks an answer failed after its
// last attempt.
const failLastTimeout = 30 * time.Second
// failLast marks an answer failed after its last grading attempt on a
// context detached from the job's, so a job that timed out or was
// cancelled still records the failure. If even that write fails, both
// errors are returned and the answer stays pending for a regrade.
func (s *Service) failLast(ctx context.Context, answerID, attemptID uint, seq int, code string, cause error) error {
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), failLastTimeout)
defer cancel()
if err := s.failGrading(fctx, answerID, attemptID, seq, code, cause); err != nil {
return errors.Join(cause, err)
}
return nil
}