Skip to content

Commit 66ca002

Browse files
leoaulasneo98ormsbee
authored andcommitted
feat: implement ExternalGraderDetail model and workflow
- Add ExternalGraderDetail model for tracking external grader submissions - Implement create_external_grader_detail function for submission creation - Add validation methods for queue_name and other required fields - Include composite index for optimized submission retrieval - Add comprehensive test suite for the new functionality - Prepare extensibility hooks for future implementations (files, queue_key)
1 parent f18dc4e commit 66ca002

8 files changed

Lines changed: 873 additions & 38 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
1. Creation of ExternalGraderDetail Model for XQueue Migration
2+
###############################################################
3+
4+
Status
5+
******
6+
7+
**Provisional** *2025-02-10*
8+
9+
Implemented by https://github.com/openedx/edx-submissions/pull/283
10+
11+
Context
12+
*******
13+
14+
Currently, Open edX uses a separate system called XQueue to handle the grading of student submissions for certain
15+
types of problems (like programming assignments). It implements a REST API with MySQL backend,
16+
requiring HTTP communication between services that manages submissions. This system works, but it has some limitations:
17+
18+
- HTTP dependency creates unnecessary synchronous coupling
19+
- Complex state management across services
20+
- No native queue system (implemented through database)
21+
- Unnecessary complexity in system architecture
22+
23+
Decision
24+
********
25+
26+
As part of Phase 1 of the XQueue migration plan, we will create a new ExternalGraderDetail model in edx-submissions to
27+
simplify the grading system architecture. This is the first step in a larger plan that will eventually include an event
28+
bus implementation for handling the submissions workflow.
29+
30+
What's New
31+
32+
A new database model that will:
33+
34+
- Keep track of submission status more clearly
35+
- Store all grading-related information in one place
36+
- Make it easier to process submissions in order
37+
- Handle errors and retries automatically
38+
39+
Key Features
40+
41+
- Better tracking of submission status (pending, being graded, completed, failed)
42+
- Clearer connection between submissions and their grading results
43+
- Improved error handling and retry capabilities
44+
- Easier monitoring of the grading process
45+
- Simplify the xqueue-watcher and edx-platform queue processing architecture
46+
- Reduce inter-service communication overhead
47+
48+
Implementation Approach
49+
50+
We'll implement this change gradually:
51+
52+
- First, build the new system alongside the existing one
53+
- Test thoroughly to ensure everything works as expected
54+
- Slowly transition from the old system to the new one
55+
- Keep the old system running until we're sure the new one works perfectly
56+
57+
Consequences
58+
************
59+
60+
Positive:
61+
---------
62+
63+
Model Structure:
64+
* Clean data separation via OneToOneField relationship
65+
* Explicit state management with VALID_TRANSITIONS
66+
* Protected state changes using atomic transactions
67+
68+
Integration:
69+
* Compatible with existing xqueue-watcher interface
70+
* Maintains current queue naming patterns
71+
* Enables parallel system operation during migration
72+
73+
Development:
74+
* Integrated status validation and retry
75+
* Comprehensive status tracking
76+
77+
Negative:
78+
---------
79+
80+
Technical Challenges:
81+
* Required atomic updates for status and timestamps
82+
* Additional database overhead from new indexes
83+
84+
Testing Needs:
85+
* Comprehensive state transition testing required
86+
* Integration testing with xqueue-watcher
87+
88+
Neutral:
89+
--------
90+
91+
Process Impact:
92+
* New queue processing patterns to learn
93+
* Additional monitoring requirements
94+
95+
Operations:
96+
* State transition monitoring needed
97+
* Temporary increased system complexity
98+
99+
References
100+
**********
101+
102+
Current System Documentation:
103+
* XQueue Repository: https://github.com/openedx/xqueue
104+
* XQueue Watcher Repository: https://github.com/openedx/xqueue-watcher
105+
106+
Migration Documents:
107+
* Current XQueue Documentation: https://github.com/openedx/edx-submissions/tree/master/docs
108+
* ADR to follow steps migration: https://github.com/openedx/edx-platform/pull/36258
109+
110+
Related Repositories:
111+
* edx-submissions: https://github.com/openedx/edx-submissions
112+
* edx-platform: https://github.com/openedx/edx-platform
113+
114+
Future Event Integration:
115+
* Open edX Events Framework: https://github.com/openedx/openedx-events
116+
* Event Bus Documentation: https://openedx.atlassian.net/wiki/spaces/AC/pages/124125264/Event+Bus
117+
118+
Related Architecture Documents:
119+
* Open edX Architecture Guidelines: https://openedx.atlassian.net/wiki/spaces/AC/pages/124125264/Architecture+Guidelines

docs/source/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ API Documentation
2323

2424
api
2525

26+
Architecture Decisions
27+
----------------------
2628

29+
.. toctree::
30+
:maxdepth: 1
31+
:glob:
32+
33+
decisions/*
2734

2835
Indices and tables
2936
==================

submissions/api.py

Lines changed: 103 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
Public interface for the submissions app.
33
44
"""
5-
65
import itertools
76
import logging
87
import operator
@@ -15,13 +14,15 @@
1514

1615
# SubmissionError imported so that code importing this api has access
1716
from submissions.errors import ( # pylint: disable=unused-import
17+
ExternalGraderQueueEmptyError,
1818
SubmissionError,
1919
SubmissionInternalError,
2020
SubmissionNotFoundError,
2121
SubmissionRequestError
2222
)
2323
from submissions.models import (
2424
DELETED,
25+
ExternalGraderDetail,
2526
Score,
2627
ScoreAnnotation,
2728
ScoreSummary,
@@ -48,64 +49,134 @@
4849
TOP_SUBMISSIONS_CACHE_TIMEOUT = 300
4950

5051

51-
def create_submission(student_item_dict, answer, submitted_at=None, attempt_number=None, team_submission=None):
52-
"""Creates a submission for assessment.
52+
# pylint: disable=unused-argument
53+
def create_external_grader_detail(student_item_dict,
54+
answer,
55+
queue_name: str,
56+
grader_file_name="",
57+
points_possible=1,
58+
**external_grader_additional_data
59+
):
60+
"""
61+
Creates a submission and an associated ExternalGraderDetail record.
62+
63+
Args:
64+
student_item_dict (dict): The student_item this submission is associated with.
65+
This is used to determine which course, student, and location the submission belongs to.
66+
67+
answer (JSON-serializable): The answer given by the student to be assessed.
68+
69+
queue_name (str): The name of the queue for the external grader.
70+
71+
grader_file_name (str, optional): The name of the grader file. Defaults to "".
72+
73+
points_possible (int, optional): The maximum possible points for this submission. Defaults to 1.
74+
75+
external_grader_additional_data: Additional keyword arguments that may be used for the external grader.
76+
77+
Returns:
78+
ExternalGraderDetail: The created external grader detail record that references the submission.
79+
80+
Raises:
81+
ExternalGraderQueueEmptyError: If queue_name is empty.
82+
SubmissionInternalError: If there's an error creating the submission or external grader detail.
83+
"""
84+
85+
submission = create_submission(student_item_dict, answer)
86+
submission_uuid = submission.get('uuid')
87+
88+
if not queue_name:
89+
raise ExternalGraderQueueEmptyError("The parameter queue_name can not be empty.")
90+
91+
try:
92+
instance = ExternalGraderDetail.create_from_uuid(
93+
submission_uuid=submission_uuid,
94+
queue_name=queue_name,
95+
grader_file_name=grader_file_name,
96+
points_possible=points_possible,
97+
98+
)
99+
return instance
100+
101+
except DatabaseError as error:
102+
error_message = (
103+
f"An error occurred while creating external grader for submission {submission_uuid}"
104+
)
105+
logger.exception(error_message)
106+
raise SubmissionInternalError(error_message) from error
107+
108+
109+
def create_submission(
110+
student_item_dict,
111+
answer,
112+
submitted_at=None,
113+
attempt_number=None,
114+
team_submission=None,
115+
):
116+
"""
117+
Creates a submission for assessment.
53118
54119
Generic means by which to submit an answer for assessment.
55120
56121
Args:
57-
student_item_dict (dict): The student_item this
58-
submission is associated with. This is used to determine which
59-
course, student, and location this submission belongs to.
122+
student_item_dict (dict): The student_item this submission is associated with.
123+
This is used to determine which course, student, and location this
124+
submission belongs to.
60125
61126
answer (JSON-serializable): The answer given by the student to be assessed.
62127
63-
submitted_at (datetime): The date in which this submission was submitted.
128+
submitted_at (datetime, optional): The date on which this submission was submitted.
64129
If not specified, defaults to the current date.
65130
66-
attempt_number (int): A student may be able to submit multiple attempts
131+
attempt_number (int, optional): A student may be able to submit multiple attempts
67132
per question. This allows the designated attempt to be overridden.
68133
If the attempt is not specified, it will take the most recent
69134
submission, as specified by the submitted_at time, and use its
70135
attempt_number plus one.
71136
137+
team_submission (TeamSubmission, optional): The team submission this individual
138+
submission is associated with, if any.
139+
72140
Returns:
73-
dict: A representation of the created Submission. The submission
74-
contains five attributes: student_item, attempt_number, submitted_at,
75-
created_at, and answer. 'student_item' is the ID of the related student
76-
item for the submission. 'attempt_number' is the attempt this submission
77-
represents for this question. 'submitted_at' represents the time this
78-
submission was submitted, which can be configured, versus the
79-
'created_at' date, which is when the submission is first created.
141+
dict: A representation of the created Submission. The submission contains
142+
five attributes: student_item, attempt_number, submitted_at, created_at,
143+
and answer.
144+
145+
The returned dictionary includes:
146+
- student_item: ID of the related student item for the submission
147+
- attempt_number: Attempt this submission represents for this question
148+
- submitted_at: Time this submission was submitted
149+
- created_at: Time the submission was first created
150+
- answer: The submitted answer
80151
81152
Raises:
82153
SubmissionRequestError: Raised when there are validation errors for the
83-
student item or submission. This can be caused by the student item
84-
missing required values, the submission being too long, the
85-
attempt_number is negative, or the given submitted_at time is invalid.
86-
SubmissionInternalError: Raised when submission access causes an
87-
internal error.
154+
student item or submission. This can occur due to:
155+
- Student item missing required values
156+
- Submission being too long
157+
- Attempt number is negative
158+
- Submitted time is invalid
159+
160+
SubmissionInternalError: Raised when submission access causes an internal error.
88161
89162
Examples:
90-
>>> student_item_dict = dict(
91-
>>> student_id="Tim",
92-
>>> item_id="item_1",
93-
>>> course_id="course_1",
94-
>>> item_type="type_one"
95-
>>> )
96-
>>> create_submission(student_item_dict, "The answer is 42.", datetime.utcnow, 1)
163+
>>> student_item_dict = {
164+
... "student_id": "Tim",
165+
... "item_id": "item_1",
166+
... "course_id": "course_1",
167+
... "item_type": "type_one"
168+
... }
169+
>>> create_submission(student_item_dict, "The answer is 42.", datetime.utcnow(), 1)
97170
{
98171
'student_item': 2,
99172
'attempt_number': 1,
100-
'submitted_at': datetime.datetime(2014, 1, 29, 17, 14, 52, 649284 tzinfo=<UTC>),
173+
'submitted_at': datetime.datetime(2014, 1, 29, 17, 14, 52, 649284, tzinfo=<UTC>),
101174
'created_at': datetime.datetime(2014, 1, 29, 17, 14, 52, 668850, tzinfo=<UTC>),
102-
'answer': u'The answer is 42.'
175+
'answer': 'The answer is 42.'
103176
}
104-
105177
"""
106178
student_item_model = _get_or_create_student_item(student_item_dict)
107179
if attempt_number is None:
108-
first_submission = None
109180
attempt_number = 1
110181
try:
111182
first_submission = Submission.objects.filter(student_item=student_item_model).first()
@@ -134,6 +205,7 @@ def create_submission(student_item_dict, answer, submitted_at=None, attempt_numb
134205
submission_serializer = SubmissionSerializer(data=model_kwargs)
135206
if not submission_serializer.is_valid():
136207
raise SubmissionRequestError(field_errors=submission_serializer.errors)
208+
137209
submission_serializer.save()
138210

139211
sub_data = submission_serializer.data

submissions/errors.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ class SubmissionNotFoundError(SubmissionError):
3131
"""
3232

3333

34+
class ExternalGraderQueueEmptyError(SubmissionError):
35+
"""
36+
This error is raised when queue name is empty.
37+
38+
If the create submission call have an event data with queue name empty,
39+
this error may be raised.
40+
"""
41+
42+
3443
class SubmissionRequestError(SubmissionError):
3544
"""
3645
This error is raised when there was a request-specific error
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Generated by Django 4.2.19 on 2025-03-25 17:25
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import django.utils.timezone
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('submissions', '0003_ensure_ascii'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='ExternalGraderDetail',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('queue_name', models.CharField(max_length=128)),
20+
('grader_file_name', models.CharField(default='', max_length=128)),
21+
('points_possible', models.PositiveIntegerField(default=1)),
22+
('status', models.CharField(choices=[('pending', 'Pending'), ('pulled', 'Pulled'),
23+
('retired', 'Retired'), ('failed', 'Failed')],
24+
default='pending', max_length=20)),
25+
('pullkey', models.CharField(blank=True, max_length=128, null=True)),
26+
('grader_reply', models.TextField(blank=True, null=True)),
27+
('status_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
28+
('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
29+
('num_failures', models.PositiveIntegerField(default=0)),
30+
('submission', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE,
31+
related_name='external_grader_detail',
32+
to='submissions.submission')),
33+
],
34+
options={
35+
'ordering': ['-created_at'],
36+
'indexes': [models.Index(fields=['queue_name', 'status', 'status_time'],
37+
name='submissions_queue_n_fbabd8_idx')],
38+
},
39+
),
40+
]

0 commit comments

Comments
 (0)