diff --git a/client/app/bundles/course/assessment/submission/actions/answers/index.js b/client/app/bundles/course/assessment/submission/actions/answers/index.js index 991a48886f7..f41dbcfb44f 100644 --- a/client/app/bundles/course/assessment/submission/actions/answers/index.js +++ b/client/app/bundles/course/assessment/submission/actions/answers/index.js @@ -11,7 +11,7 @@ import { getClientVersionForAnswerId } from '../../selectors/answers'; import translations from '../../translations'; import { convertAnswerDataToInitialValue } from '../../utils/answers'; import { buildErrorMessage, formatAnswer } from '../utils'; -import { fetchSubmission, getEvaluationResult } from '..'; +import { fetchSubmission } from '..'; const JOB_POLL_DELAY_MS = 500; export const STALE_ANSWER_ERR = 'stale_answer'; @@ -33,28 +33,10 @@ export const updateClientVersion = (answerId, clientVersion) => (dispatch) => payload: { answer: { id: answerId, clientVersion } }, }); -const pollAutogradingJob = - (jobUrl, submissionId, questionId, answerId) => (dispatch) => { - pollJob( - jobUrl, - () => dispatch(getEvaluationResult(submissionId, answerId, questionId)), - (errorData) => { - dispatch({ - type: actionTypes.AUTOGRADE_FAILURE, - questionId, - payload: errorData, - }); - dispatch(setNotification(translations.requestFailure)); - }, - JOB_POLL_DELAY_MS, - ); - }; - -export function submitAnswer(submissionId, answerId, rawAnswer, resetField) { +export function submitAnswer(questionId, answerId, rawAnswer, resetField) { const currentTime = Date.now(); const answer = formatAnswer(rawAnswer, currentTime); const payload = { answer }; - const questionId = answer.questionId; return (dispatch) => { dispatch(updateClientVersion(answerId, currentTime)); @@ -73,16 +55,14 @@ export function submitAnswer(submissionId, answerId, rawAnswer, resetField) { if (data.newSessionUrl) { window.location = data.newSessionUrl; } else if (data.jobUrl) { - pollAutogradingJob( - data.jobUrl, - submissionId, - questionId, - answerId, - )(dispatch); + dispatch({ + type: actionTypes.AUTOGRADE_SUBMITTED, + payload: { questionId, jobUrl: data.jobUrl }, + }); } else { dispatch({ type: actionTypes.AUTOGRADE_SUCCESS, - payload: data, + payload: { ...data, answerId }, }); // When an answer is submitted, the value of that field needs to be updated. resetField(`${answerId}`, { @@ -353,20 +333,10 @@ export function reevaluateAnswer(submissionId, answerId, questionId) { if (data.newSessionUrl) { window.location = data.newSessionUrl; } else if (data.jobUrl) { - pollJob( - data.jobUrl, - () => - dispatch(getEvaluationResult(submissionId, answerId, questionId)), - (errorData) => { - dispatch({ - type: actionTypes.REEVALUATE_FAILURE, - questionId, - payload: errorData, - }); - dispatch(setNotification(translations.requestFailure)); - }, - JOB_POLL_DELAY_MS, - ); + dispatch({ + type: actionTypes.REEVALUATE_SUBMITTED, + payload: { questionId, jobUrl: data.jobUrl }, + }); } else { dispatch({ type: actionTypes.REEVALUATE_SUCCESS, diff --git a/client/app/bundles/course/assessment/submission/actions/index.js b/client/app/bundles/course/assessment/submission/actions/index.js index 24f5db20428..99bb076bf19 100644 --- a/client/app/bundles/course/assessment/submission/actions/index.js +++ b/client/app/bundles/course/assessment/submission/actions/index.js @@ -1,3 +1,4 @@ +import GlobalAPI from 'api'; import CourseAPI from 'api/course'; import { setNotification } from 'lib/actions'; import pollJob from 'lib/helpers/jobHelpers'; @@ -12,7 +13,6 @@ import translations from '../translations'; import { buildErrorMessage, formatAnswers } from './utils'; const JOB_POLL_DELAY_MS = 500; -const JOB_STAGGER_DELAY_MS = 400; export function getEvaluationResult(submissionId, answerId, questionId) { return (dispatch) => { @@ -22,16 +22,20 @@ export function getEvaluationResult(submissionId, answerId, questionId) { .then((data) => { dispatch({ type: actionTypes.AUTOGRADE_SUCCESS, - payload: data, + payload: { ...data, answerId }, }); }) .catch(() => { dispatch(setNotification(translations.requestFailure)); - dispatch({ type: actionTypes.AUTOGRADE_FAILURE, questionId }); + dispatch({ type: actionTypes.AUTOGRADE_FAILURE, questionId, answerId }); }); }; } +export function getJobStatus(jobUrl) { + return GlobalAPI.jobs.get(jobUrl); +} + export function fetchSubmission(id, onGetMonitoringSessionId) { return (dispatch) => { dispatch({ type: actionTypes.FETCH_SUBMISSION_REQUEST }); @@ -48,29 +52,6 @@ export function fetchSubmission(id, onGetMonitoringSessionId) { window.location = data.newSessionUrl; return; } - data.answers - .filter((a) => a.autograding && a.autograding.path) - .forEach((answer, index) => { - setTimeout(() => { - pollJob( - answer.autograding.path, - () => - dispatch( - getEvaluationResult( - id, - answer.fields.id, - answer.questionId, - ), - ), - () => - dispatch({ - type: actionTypes.AUTOGRADE_FAILURE, - questionId: answer.questionId, - }), - JOB_POLL_DELAY_MS, - ); - }, JOB_STAGGER_DELAY_MS * index); - }); if (data.monitoringSessionId !== undefined) onGetMonitoringSessionId?.(data.monitoringSessionId); dispatch({ diff --git a/client/app/bundles/course/assessment/submission/constants.ts b/client/app/bundles/course/assessment/submission/constants.ts index c8c1c2ff0d1..bc90c55b0f0 100644 --- a/client/app/bundles/course/assessment/submission/constants.ts +++ b/client/app/bundles/course/assessment/submission/constants.ts @@ -22,7 +22,8 @@ export const BUFFER_TIME_TO_FORCE_SUBMIT_MS = 5 * 1000; // to still be considered a "newly created" submission export const TIME_LAPSE_NEW_SUBMISSION_MS = 10 * 1000; -export const POLL_INTERVAL_MILLISECONDS = 2000; +export const EVALUATE_POLL_INTERVAL_MILLISECONDS = 500; +export const FEEDBACK_POLL_INTERVAL_MILLISECONDS = 2000; export const workflowStates = { Unstarted: 'unstarted' as const, @@ -160,6 +161,7 @@ const actionTypes = mirrorCreator([ 'UNSUBMIT_SUCCESS', 'UNSUBMIT_FAILURE', 'AUTOGRADE_REQUEST', + 'AUTOGRADE_SUBMITTED', 'AUTOGRADE_SUCCESS', 'AUTOGRADE_FAILURE', 'AUTOGRADE_SAVING_SUCCESS', @@ -168,6 +170,7 @@ const actionTypes = mirrorCreator([ 'FEEDBACK_SUCCESS', 'FEEDBACK_FAILURE', 'REEVALUATE_REQUEST', + 'REEVALUATE_SUBMITTED', 'REEVALUATE_SUCCESS', 'REEVALUATE_FAILURE', 'RESET_REQUEST', diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx index 87524c1d65e..f5e3b8ef394 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx @@ -7,12 +7,13 @@ import usePrompt from 'lib/hooks/router/usePrompt'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import { finalise } from '../../actions'; +import { finalise, getEvaluationResult, getJobStatus } from '../../actions'; import { fetchLiveFeedback } from '../../actions/answers'; import WarningDialog from '../../components/WarningDialog'; -import { +import actionTypes, { + EVALUATE_POLL_INTERVAL_MILLISECONDS, + FEEDBACK_POLL_INTERVAL_MILLISECONDS, formNames, - POLL_INTERVAL_MILLISECONDS, workflowStates, } from '../../constants'; import GradingPanel from '../../containers/GradingPanel'; @@ -20,6 +21,7 @@ import { getInitialAnswer } from '../../selectors/answers'; import { getAssessment } from '../../selectors/assessments'; import { getAttachments } from '../../selectors/attachments'; import { getLiveFeedbacks } from '../../selectors/liveFeedbacks'; +import { getQuestionFlags } from '../../selectors/questionFlags'; import { getQuestions } from '../../selectors/questions'; import { getSubmission } from '../../selectors/submissions'; import translations from '../../translations'; @@ -51,6 +53,7 @@ const SubmissionForm: FC = (props) => { const assessment = useAppSelector(getAssessment); const submission = useAppSelector(getSubmission); const questions = useAppSelector(getQuestions); + const questionFlags = useAppSelector(getQuestionFlags); const attachments = useAppSelector(getAttachments); const liveFeedbacks = useAppSelector(getLiveFeedbacks); const initialValues = useAppSelector(getInitialAnswer); @@ -144,7 +147,8 @@ const SubmissionForm: FC = (props) => { } }); - const pollerRef = useRef(null); + const feedbackPollerRef = useRef(null); + const evaluatePollerRef = useRef(null); const pollAllFeedback = (): void => { questionIds.forEach((id) => { const question = questions[id]; @@ -156,17 +160,59 @@ const SubmissionForm: FC = (props) => { }); }; + const handleEvaluationPolling = (): void => { + Object.values(questions).forEach((question) => { + if ( + questionFlags[question.id]?.isAutograding && + questionFlags[question.id]?.jobUrl + ) { + getJobStatus(questionFlags[question.id].jobUrl).then((response) => { + switch (response.data.status) { + case 'submitted': + break; + case 'completed': + dispatch( + getEvaluationResult( + submissionId, + question.answerId, + question.id, + ), + ); + break; + case 'errored': + dispatch({ + type: actionTypes.AUTOGRADE_FAILURE, + answerId: question.answerId, + questionId: question.id, + }); + break; + default: + throw new Error('Unknown job status'); + } + }); + } + }); + }; + useEffect(() => { // check for feedback from Codaveri on page load for each question - pollerRef.current = setInterval( + feedbackPollerRef.current = setInterval( pollAllFeedback, - POLL_INTERVAL_MILLISECONDS, + FEEDBACK_POLL_INTERVAL_MILLISECONDS, + ); + + evaluatePollerRef.current = setInterval( + handleEvaluationPolling, + EVALUATE_POLL_INTERVAL_MILLISECONDS, ); // clean up poller on unmount return () => { - if (pollerRef.current) { - clearInterval(pollerRef.current); + if (feedbackPollerRef.current) { + clearInterval(feedbackPollerRef.current); + } + if (evaluatePollerRef.current) { + clearInterval(evaluatePollerRef.current); } }; }); diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/RunCodeButton.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/RunCodeButton.tsx index e1e7c56fbbb..141420eadf4 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/RunCodeButton.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/RunCodeButton.tsx @@ -9,7 +9,6 @@ import { getSubmissionFlags } from 'course/assessment/submission/selectors/submi import { getSubmission } from 'course/assessment/submission/selectors/submissions'; import translations from 'course/assessment/submission/translations'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import { getSubmissionId } from 'lib/helpers/url-helpers'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -30,7 +29,6 @@ const RunCodeButton: FC = (props) => { const { resetField, getValues } = useFormContext(); const question = questions[questionId]; - const submissionId = getSubmissionId(); const { answerId, attemptsLeft, attemptLimit } = question; const { isAutograding, isResetting } = questionFlags[questionId] || {}; @@ -39,12 +37,7 @@ const RunCodeButton: FC = (props) => { const onSubmitAnswer = (): void => { dispatch( - submitAnswer( - submissionId, - answerId, - getValues(`${answerId}`), - resetField, - ), + submitAnswer(question.id, answerId, getValues(`${answerId}`), resetField), ); }; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/SubmitButton.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/SubmitButton.tsx index 897252d72a3..6ebe954d7f2 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/SubmitButton.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/SubmitButton.tsx @@ -12,7 +12,6 @@ import { getQuestions } from 'course/assessment/submission/selectors/questions'; import { getSubmissionFlags } from 'course/assessment/submission/selectors/submissionFlags'; import translations from 'course/assessment/submission/translations'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import { getSubmissionId } from 'lib/helpers/url-helpers'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -33,8 +32,6 @@ const SubmitButton: FC = (props) => { const { resetField, getValues } = useFormContext(); - const submissionId = getSubmissionId(); - const question = questions[questionId]; const { answerId, autogradable, type } = question; @@ -51,12 +48,7 @@ const SubmitButton: FC = (props) => { const onSubmitAnswer = (): void => { dispatch( - submitAnswer( - submissionId, - answerId, - getValues(`${answerId}`), - resetField, - ), + submitAnswer(question.id, answerId, getValues(`${answerId}`), resetField), ); }; diff --git a/client/app/bundles/course/assessment/submission/reducers/questionsFlags.js b/client/app/bundles/course/assessment/submission/reducers/questionsFlags.js index 07afc4f3064..1a0ac99afb8 100644 --- a/client/app/bundles/course/assessment/submission/reducers/questionsFlags.js +++ b/client/app/bundles/course/assessment/submission/reducers/questionsFlags.js @@ -1,28 +1,33 @@ import actions from '../constants'; +function initQuestionsFlagsFromSubmissionPayload(payload) { + return payload.questions.reduce((obj, question) => { + const answer = payload.answers.find( + (ans) => ans.questionId === question.id, + ); + return { + ...obj, + [question.id]: { + isResetting: false, + isAutograding: + Boolean(answer?.autograding) && + answer?.autograding?.status === 'submitted', + jobUrl: answer?.autograding?.jobUrl, + jobError: + Boolean(answer?.autograding) && + answer?.autograding?.status === 'errored', + jobErrorMessage: answer?.autograding?.errorMessage, + }, + }; + }, {}); +} + export default function (state = {}, action) { switch (action.type) { case actions.FETCH_SUBMISSION_SUCCESS: case actions.UNSUBMIT_SUCCESS: case actions.FINALISE_SUCCESS: - return action.payload.questions.reduce((obj, question) => { - const answer = action.payload.answers.find( - (ans) => ans.questionId === question.id, - ); - return { - ...obj, - [question.id]: { - isResetting: false, - isAutograding: - Boolean(answer?.autograding) && - answer?.autograding?.status === 'submitted', - jobError: - Boolean(answer?.autograding) && - answer?.autograding?.status === 'errored', - jobErrorMessage: answer?.autograding?.errorMessage, - }, - }; - }, {}); + return initQuestionsFlagsFromSubmissionPayload(action.payload); case actions.REEVALUATE_REQUEST: case actions.AUTOGRADE_REQUEST: { const { questionId } = action.payload; @@ -31,6 +36,19 @@ export default function (state = {}, action) { [questionId]: { ...state[questionId], isAutograding: true, + jobUrl: null, + }, + }; + } + case actions.REEVALUATE_SUBMITTED: + case actions.AUTOGRADE_SUBMITTED: { + const { questionId, jobUrl } = action.payload; + return { + ...state, + [questionId]: { + ...state[questionId], + isAutograding: true, + jobUrl, }, }; } @@ -42,7 +60,9 @@ export default function (state = {}, action) { [questionId]: { ...state[questionId], isAutograding: false, + jobUrl: null, jobError: false, + jobErrorMessage: null, }, }; } @@ -55,6 +75,7 @@ export default function (state = {}, action) { [questionId]: { ...state[questionId], isAutograding: false, + jobUrl: null, jobError: Boolean(jobError), jobErrorMessage: payload?.errorMessage, }, diff --git a/client/app/bundles/course/assessment/submission/types.ts b/client/app/bundles/course/assessment/submission/types.ts index 16607c990c1..0712f424baf 100644 --- a/client/app/bundles/course/assessment/submission/types.ts +++ b/client/app/bundles/course/assessment/submission/types.ts @@ -127,6 +127,7 @@ export interface SubmissionFlagsState { export interface QuestionFlag { isAutograding: boolean; isResetting: boolean; + jobUrl: string | null; jobError: boolean; jobErrorMessage?: string; }