diff --git a/docs/task4-compatibility-boundary-evidence.md b/docs/task4-compatibility-boundary-evidence.md new file mode 100644 index 0000000..b751645 --- /dev/null +++ b/docs/task4-compatibility-boundary-evidence.md @@ -0,0 +1,21 @@ +# 任务四:提交失败恢复与可观测性证据 + +## 问题/目标 + +`POST /api/submit` 的旧调用仍使用 `code`、`assignment_id`,默认语言仍为 `cpp`。同步评估器抛出异常时,提交已经以 `pending` 写入数据库;目标是在不改变旧响应契约的前提下,保证记录可恢复为 `failed`,并留下可定位日志。 + +## 复现与证据 + +在测试中用 `patch('routes.api.evaluate_cpp_code', side_effect=RuntimeError(...))` 调用真实 `/api/submit` 路由。修复前异常分支没有回滚会话,也没有保存异常原因;这会让失败记录缺少恢复信息。测试验证 HTTP 仍为 `500`,同时检查数据库状态和应用日志。 + +## 计划改动 + +只修改 `routes/api.py:submit_code` 的同步评估异常分支:先 `db.session.rollback()`,再将同一提交标记为 `failed` 并保存反馈,最后用 `current_app.logger.exception` 记录 `submission_id`。不改请求字段、成功响应、错误状态码、队列接口或数据库结构。 + +## 学习总结 + +人工排查沿着 `pending -> evaluated/failed` 状态路径追踪,并把“评估器异常后是否可恢复”写成可证伪假设。拒绝统一错误码、幂等键和异步队列重构等过度设计,因为它们会改变公开契约,不能由当前故障证明必要。 + +## 验证结果 + +`python -m pytest tests/test_task4_submission_failure_recovery.py -q`:`1 passed`。回归测试覆盖旧请求字段、500 响应、`failed` 状态、异常反馈和带提交 ID 的应用日志。 diff --git a/routes/api.py b/routes/api.py index 1aee78a..b652b5a 100644 --- a/routes/api.py +++ b/routes/api.py @@ -813,10 +813,15 @@ def submit_code(): ) except Exception as e: - current_app.logger.exception('评估代码失败') - + # 评估可能在事务中途失败,先回滚会话再保存可恢复的失败状态。 + db.session.rollback() submission.status = 'failed' + submission.feedback = f'代码评估失败: {str(e)}' db.session.commit() + current_app.logger.exception( + '评估代码失败,提交已标记为 failed (submission_id=%s)', + submission.id, + ) return error_response("代码评估失败,请稍后重试", 500) diff --git a/tests/test_task4_submission_failure_recovery.py b/tests/test_task4_submission_failure_recovery.py new file mode 100644 index 0000000..c99d75d --- /dev/null +++ b/tests/test_task4_submission_failure_recovery.py @@ -0,0 +1,57 @@ +import os +import tempfile +from unittest.mock import patch + +from app import create_app +from models import Assignment, Submission, User, db + + +def test_sync_submission_failure_is_persisted_and_observable(caplog): + fd, database_path = tempfile.mkstemp() + app = create_app('testing') + app.config.update( + SQLALCHEMY_DATABASE_URI=f'sqlite:///{database_path}', + TESTING=True, + SUBMISSION_EVALUATION_QUEUE_BACKEND='thread', + ) + client = app.test_client() + try: + with app.app_context(): + db.drop_all() + db.create_all() + student = User( + student_id='task4-student', + username='task4-student', + usertype='学生', + full_name='任务四学生', + ) + student.password = 'password' + db.session.add(student) + assignment = Assignment(title='任务四作业', creator_id=student.student_id) + db.session.add(assignment) + db.session.commit() + assignment_id = assignment.id + + client.post('/login', data={'username': 'task4-student', 'password': 'password'}) + with patch( + 'routes.api.evaluate_cpp_code', + side_effect=RuntimeError('temporary evaluator outage'), + ): + response = client.post( + '/api/submit', + json={'code': 'int main() { return 0; }', 'assignment_id': assignment_id}, + ) + + assert response.status_code == 500 + assert response.get_json()['success'] is False + with app.app_context(): + submission = Submission.query.one() + assert submission.status == 'failed' + assert 'temporary evaluator outage' in submission.feedback + assert 'submission_id=' in caplog.text + finally: + with app.app_context(): + db.session.remove() + db.drop_all() + os.close(fd) + os.unlink(database_path)