api_helper.py 15.1 KB
Newer Older
1 2 3 4 5 6
"""
Helper lib for instructor_tasks API.

Includes methods to check args for rescoring task, encoding student input,
and task submission logic, including handling the Celery backend.
"""
7 8 9
import hashlib
import json
import logging
Brian Wilson committed
10

11 12
from django.utils.translation import ugettext as _

13 14 15 16
from celery.result import AsyncResult
from celery.states import READY_STATES, SUCCESS, FAILURE, REVOKED

from courseware.module_render import get_xqueue_callback_url_prefix
17
from courseware.courses import get_problems_in_section
18 19

from xmodule.modulestore.django import modulestore
20
from opaque_keys.edx.keys import UsageKey
21
from instructor_task.models import InstructorTask, PROGRESS
22 23


24
log = logging.getLogger(__name__)
25 26 27


class AlreadyRunningError(Exception):
28
    """Exception indicating that a background task is already running"""
29 30 31 32 33
    pass


def _task_is_running(course_id, task_type, task_key):
    """Checks if a particular task is already running"""
34 35 36
    running_tasks = InstructorTask.objects.filter(
        course_id=course_id, task_type=task_type, task_key=task_key
    )
37 38
    # exclude states that are "ready" (i.e. not "running", e.g. failure, success, revoked):
    for state in READY_STATES:
39 40
        running_tasks = running_tasks.exclude(task_state=state)
    return len(running_tasks) > 0
41 42 43 44 45 46 47


def _reserve_task(course_id, task_type, task_key, task_input, requester):
    """
    Creates a database entry to indicate that a task is in progress.

    Throws AlreadyRunningError if the task is already in progress.
Brian Wilson committed
48 49
    Includes the creation of an arbitrary value for task_id, to be
    submitted with the task call to celery.
50

51
    The InstructorTask.create method makes sure the InstructorTask entry is committed.
Brian Wilson committed
52
    When called from any view that is wrapped by TransactionMiddleware,
53
    and thus in a "commit-on-success" transaction, an autocommit buried within here
Brian Wilson committed
54 55 56
    will cause any pending transaction to be committed by a successful
    save here.  Any future database operations will take place in a
    separate transaction.
Brian Wilson committed
57 58 59 60 61 62 63

    Note that there is a chance of a race condition here, when two users
    try to run the same task at almost exactly the same time.  One user
    could be after the check and before the create when the second user
    gets to the check.  At that point, both users are able to run their
    tasks simultaneously.  This is deemed a small enough risk to not
    put in further safeguards.
64 65 66
    """

    if _task_is_running(course_id, task_type, task_key):
67
        log.warning("Duplicate task found for task_type %s and task_key %s", task_type, task_key)
68 69
        raise AlreadyRunningError("requested task is already running")

70 71 72 73 74 75 76 77 78 79 80 81
    try:
        most_recent_id = InstructorTask.objects.latest('id').id
    except InstructorTask.DoesNotExist:
        most_recent_id = "None found"
    finally:
        log.warning(
            "No duplicate tasks found: task_type %s, task_key %s, and most recent task_id = %s",
            task_type,
            task_key,
            most_recent_id
        )

82 83
    # Create log entry now, so that future requests will know it's running.
    return InstructorTask.create(course_id, task_type, task_key, task_input, requester)
84 85


86
def _get_xmodule_instance_args(request, task_id):
87 88 89 90 91 92
    """
    Calculate parameters needed for instantiating xmodule instances.

    The `request_info` will be passed to a tracking log function, to provide information
    about the source of the task request.   The `xqueue_callback_url_prefix` is used to
    permit old-style xqueue callbacks directly to the appropriate module in the LMS.
93
    The `task_id` is also passed to the tracking log function.
94 95 96 97 98 99 100 101 102
    """
    request_info = {'username': request.user.username,
                    'ip': request.META['REMOTE_ADDR'],
                    'agent': request.META.get('HTTP_USER_AGENT', ''),
                    'host': request.META['SERVER_NAME'],
                    }

    xmodule_instance_args = {'xqueue_callback_url_prefix': get_xqueue_callback_url_prefix(request),
                             'request_info': request_info,
103
                             'task_id': task_id,
104 105 106 107 108 109 110 111
                             }
    return xmodule_instance_args


def _update_instructor_task(instructor_task, task_result):
    """
    Updates and possibly saves a InstructorTask entry based on a task Result.

Brian Wilson committed
112
    Used when updated status is requested.
113 114 115 116 117

    The `instructor_task` that is passed in is updated in-place, but
    is usually not saved.  In general, tasks that have finished (either with
    success or failure) should have their entries updated by the task itself,
    so are not updated here.  Tasks that are still running are not updated
118
    and saved while they run.  The one exception to the no-save rule are tasks that
119 120 121
    are in a "revoked" state.  This may mean that the task never had the
    opportunity to update the InstructorTask entry.

122 123 124 125 126 127
    Tasks that are in progress and have subtasks doing the processing do not look
    to the task's AsyncResult object.  When subtasks are running, the
    InstructorTask object itself is updated with the subtasks' progress,
    not any AsyncResult object.  In this case, the InstructorTask is
    not updated at all.

128
    Calculates json to store in "task_output" field of the `instructor_task`,
Brian Wilson committed
129
    as well as updating the task_state.
130

Brian Wilson committed
131 132 133
    For a successful task, the json contains the output of the task result.
    For a failed task, the json contains "exception", "message", and "traceback"
    keys.   A revoked task just has a "message" stating it was revoked.
134 135 136 137 138 139 140 141 142 143
    """
    # Pull values out of the result object as close to each other as possible.
    # If we wait and check the values later, the values for the state and result
    # are more likely to have changed.  Pull the state out first, and
    # then code assuming that the result may not exactly match the state.
    task_id = task_result.task_id
    result_state = task_result.state
    returned_result = task_result.result
    result_traceback = task_result.traceback

144 145 146 147
    # Assume we don't always save the InstructorTask entry if we don't have to,
    # but that in most cases we will update the InstructorTask in-place with its
    # current progress.
    entry_needs_updating = True
148
    entry_needs_saving = False
149
    task_output = None
150

151
    if instructor_task.task_state == PROGRESS and len(instructor_task.subtasks) > 0:
152 153 154 155 156 157 158
        # This happens when running subtasks:  the result object is marked with SUCCESS,
        # meaning that the subtasks have successfully been defined.  However, the InstructorTask
        # will be marked as in PROGRESS, until the last subtask completes and marks it as SUCCESS.
        # We want to ignore the parent SUCCESS if subtasks are still running, and just trust the
        # contents of the InstructorTask.
        entry_needs_updating = False
    elif result_state in [PROGRESS, SUCCESS]:
159 160
        # construct a status message directly from the task result's result:
        # it needs to go back with the entry passed in.
Brian Wilson committed
161
        log.info("background task (%s), state %s:  result: %s", task_id, result_state, returned_result)
162
        task_output = InstructorTask.create_output_for_success(returned_result)
163 164 165 166 167
    elif result_state == FAILURE:
        # on failure, the result's result contains the exception that caused the failure
        exception = returned_result
        traceback = result_traceback if result_traceback is not None else ''
        log.warning("background task (%s) failed: %s %s", task_id, returned_result, traceback)
168
        task_output = InstructorTask.create_output_for_failure(exception, result_traceback)
169 170 171 172 173 174
    elif result_state == REVOKED:
        # on revocation, the result's result doesn't contain anything
        # but we cannot rely on the worker thread to set this status,
        # so we set it here.
        entry_needs_saving = True
        log.warning("background task (%s) revoked.", task_id)
175
        task_output = InstructorTask.create_output_for_revoked()
176

Brian Wilson committed
177 178 179
    # save progress and state into the entry, even if it's not being saved:
    # when celery is run in "ALWAYS_EAGER" mode, progress needs to go back
    # with the entry passed in.
180 181 182 183
    if entry_needs_updating:
        instructor_task.task_state = result_state
        if task_output is not None:
            instructor_task.task_output = task_output
184

185 186
        if entry_needs_saving:
            instructor_task.save()
187 188


Brian Wilson committed
189 190 191
def get_updated_instructor_task(task_id):
    """
    Returns InstructorTask object corresponding to a given `task_id`.
192

Brian Wilson committed
193 194 195
    If the InstructorTask thinks the task is still running, then
    the task's result is checked to return an updated state and output.
    """
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211
    # First check if the task_id is known
    try:
        instructor_task = InstructorTask.objects.get(task_id=task_id)
    except InstructorTask.DoesNotExist:
        log.warning("query for InstructorTask status failed: task_id=(%s) not found", task_id)
        return None

    # if the task is not already known to be done, then we need to query
    # the underlying task's result object:
    if instructor_task.task_state not in READY_STATES:
        result = AsyncResult(task_id)
        _update_instructor_task(instructor_task, result)

    return instructor_task


Brian Wilson committed
212
def get_status_from_instructor_task(instructor_task):
213
    """
Brian Wilson committed
214
    Get the status for a given InstructorTask entry.
215 216

    Returns a dict, with the following keys:
Brian Wilson committed
217 218 219
      'task_id': id assigned by LMS and used by celery.
      'task_state': state of task as stored in celery's result store.
      'in_progress': boolean indicating if task is still running.
220 221
      'task_progress': dict containing progress information.  This includes:
          'attempted': number of attempts made
222
          'succeeded': number of attempts that "succeeded"
223 224 225
          'total': number of possible subtasks to attempt
          'action_name': user-visible verb to use in status messages.  Should be past-tense.
          'duration_ms': how long the task has (or had) been running.
Brian Wilson committed
226 227 228
          'exception': name of exception class raised in failed tasks.
          'message': returned for failed and revoked tasks.
          'traceback': optional, returned if task failed and produced a traceback.
229

230
     """
231 232
    status = {}

233 234 235 236 237 238 239
    if instructor_task is not None:
        # status basic information matching what's stored in InstructorTask:
        status['task_id'] = instructor_task.task_id
        status['task_state'] = instructor_task.task_state
        status['in_progress'] = instructor_task.task_state not in READY_STATES
        if instructor_task.task_output is not None:
            status['task_progress'] = json.loads(instructor_task.task_output)
240 241 242 243

    return status


244
def check_arguments_for_rescoring(usage_key):
245 246 247
    """
    Do simple checks on the descriptor to confirm that it supports rescoring.

248
    Confirms first that the usage_key is defined (since that's currently typed
249 250 251 252
    in).  An ItemNotFoundException is raised if the corresponding module
    descriptor doesn't exist.  NotImplementedError is raised if the
    corresponding module doesn't support rescoring calls.
    """
253
    descriptor = modulestore().get_item(usage_key)
254 255 256 257 258
    if not hasattr(descriptor, 'module_class') or not hasattr(descriptor.module_class, 'rescore_problem'):
        msg = "Specified module does not support rescoring."
        raise NotImplementedError(msg)


259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274
def check_entrance_exam_problems_for_rescoring(exam_key):  # pylint: disable=invalid-name
    """
    Grabs all problem descriptors in exam and checks each descriptor to
    confirm that it supports re-scoring.

    An ItemNotFoundException is raised if the corresponding module
    descriptor doesn't exist for exam_key. NotImplementedError is raised if
    any of the problem in entrance exam doesn't support re-scoring calls.
    """
    problems = get_problems_in_section(exam_key).values()
    if any(not hasattr(problem, 'module_class') or not hasattr(problem.module_class, 'rescore_problem')
           for problem in problems):
        msg = _("Not all problems in entrance exam support re-scoring.")
        raise NotImplementedError(msg)


275
def encode_problem_and_student_input(usage_key, student=None):  # pylint: disable=invalid-name
276
    """
277
    Encode optional usage_key and optional student into task_key and task_input values.
278

279 280 281
    Args:
        usage_key (Location): The usage_key identifying the problem.
        student (User): the student affected
282
    """
283

284
    assert isinstance(usage_key, UsageKey)
285
    if student is not None:
286 287
        task_input = {'problem_url': usage_key.to_deprecated_string(), 'student': student.username}
        task_key_stub = "{student}_{problem}".format(student=student.id, problem=usage_key.to_deprecated_string())
288
    else:
289 290
        task_input = {'problem_url': usage_key.to_deprecated_string()}
        task_key_stub = "_{problem}".format(problem=usage_key.to_deprecated_string())
291 292 293 294 295 296 297

    # create the key value by using MD5 hash:
    task_key = hashlib.md5(task_key_stub).hexdigest()

    return task_input, task_key


298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319
def encode_entrance_exam_and_student_input(usage_key, student=None):  # pylint: disable=invalid-name
    """
    Encode usage_key and optional student into task_key and task_input values.

    Args:
        usage_key (Location): The usage_key identifying the entrance exam.
        student (User): the student affected
    """
    assert isinstance(usage_key, UsageKey)
    if student is not None:
        task_input = {'entrance_exam_url': unicode(usage_key), 'student': student.username}
        task_key_stub = "{student}_{entranceexam}".format(student=student.id, entranceexam=unicode(usage_key))
    else:
        task_input = {'entrance_exam_url': unicode(usage_key)}
        task_key_stub = "_{entranceexam}".format(entranceexam=unicode(usage_key))

    # create the key value by using MD5 hash:
    task_key = hashlib.md5(task_key_stub).hexdigest()

    return task_input, task_key


320
def submit_task(request, task_type, task_class, course_key, task_input, task_key):
321 322 323
    """
    Helper method to submit a task.

324
    Reserves the requested task, based on the `course_key`, `task_type`, and `task_key`,
325 326 327
    checking to see if the task is already running.  The `task_input` is also passed so that
    it can be stored in the resulting InstructorTask entry.  Arguments are extracted from
    the `request` provided by the originating server request.  Then the task is submitted to run
Brian Wilson committed
328
    asynchronously, using the specified `task_class` and using the task_id constructed for it.
329 330

    `AlreadyRunningError` is raised if the task is already running.
Brian Wilson committed
331 332 333 334 335 336 337 338

    The _reserve_task method makes sure the InstructorTask entry is committed.
    When called from any view that is wrapped by TransactionMiddleware,
    and thus in a "commit-on-success" transaction, an autocommit buried within here
    will cause any pending transaction to be committed by a successful
    save here.  Any future database operations will take place in a
    separate transaction.

339 340
    """
    # check to see if task is already running, and reserve it otherwise:
341
    instructor_task = _reserve_task(course_key, task_type, task_key, task_input, request.user)
342 343

    # submit task:
Brian Wilson committed
344
    task_id = instructor_task.task_id
345
    task_args = [instructor_task.id, _get_xmodule_instance_args(request, task_id)]  # pylint: disable=no-member
Brian Wilson committed
346
    task_class.apply_async(task_args, task_id=task_id)
347

348
    return instructor_task