diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java index 7a4220399819..e8fce4fcb230 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java @@ -29,8 +29,9 @@ public record BuildJobQueueItem(String id, String name, BuildAgentDTO buildAgent */ public BuildJobQueueItem(BuildJobQueueItem queueItem, ZonedDateTime buildCompletionDate, BuildStatus status) { this(queueItem.id(), queueItem.name(), queueItem.buildAgent(), queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), - queueItem.priority(), status, queueItem.repositoryInfo(), - new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), queueItem.jobTimingInfo.buildStartDate(), buildCompletionDate), queueItem.buildConfig(), null); + queueItem.priority(), status, queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), queueItem.jobTimingInfo.buildStartDate(), + buildCompletionDate, queueItem.jobTimingInfo.estimatedCompletionDate(), queueItem.jobTimingInfo.estimatedDuration()), + queueItem.buildConfig(), null); } /** @@ -39,9 +40,11 @@ public BuildJobQueueItem(BuildJobQueueItem queueItem, ZonedDateTime buildComplet * @param queueItem The queued build job * @param buildAgent The build agent that will process the build job */ - public BuildJobQueueItem(BuildJobQueueItem queueItem, BuildAgentDTO buildAgent) { + public BuildJobQueueItem(BuildJobQueueItem queueItem, BuildAgentDTO buildAgent, ZonedDateTime estimatedCompletionDate) { this(queueItem.id(), queueItem.name(), buildAgent, queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), queueItem.priority(), - null, queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null), queueItem.buildConfig(), null); + null, queueItem.repositoryInfo(), + new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null, estimatedCompletionDate, queueItem.jobTimingInfo.estimatedDuration()), + queueItem.buildConfig(), null); } public BuildJobQueueItem(BuildJobQueueItem queueItem, ResultDTO submissionResult) { diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/FinishedBuildJobDTO.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/FinishedBuildJobDTO.java index c7086c9acf29..d7df788928aa 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/FinishedBuildJobDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/FinishedBuildJobDTO.java @@ -36,7 +36,7 @@ public record ResultDTO(Long id, ZonedDateTime completionDate, Boolean successfu * @return the converted DTO */ public static ResultDTO of(Result result) { - SubmissionDTO submissionDTO = result.getSubmission() == null ? null : SubmissionDTO.of(result.getSubmission()); + SubmissionDTO submissionDTO = result.getSubmission() == null ? null : SubmissionDTO.of(result.getSubmission(), false, null, null); return new ResultDTO(result.getId(), result.getCompletionDate(), result.isSuccessful(), result.getScore(), result.isRated(), ParticipationDTO.of(result.getParticipation()), submissionDTO, result.getAssessmentType(), result.getTestCaseCount(), result.getPassedTestCaseCount(), diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/JobTimingInfo.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/JobTimingInfo.java index 8de38115f6b6..f344bff2c3a8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/JobTimingInfo.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/JobTimingInfo.java @@ -10,5 +10,6 @@ // in the future are migrated or cleared. Changes should be communicated in release notes as potentially breaking changes. @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record JobTimingInfo(ZonedDateTime submissionDate, ZonedDateTime buildStartDate, ZonedDateTime buildCompletionDate) implements Serializable { +public record JobTimingInfo(ZonedDateTime submissionDate, ZonedDateTime buildStartDate, ZonedDateTime buildCompletionDate, ZonedDateTime estimatedCompletionDate, + long estimatedDuration) implements Serializable { } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java index 0823ec5a4f9b..cff8d97dac40 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java @@ -273,7 +273,7 @@ private void checkAvailabilityAndProcessNextBuild() { if (buildJob != null) { processingJobs.remove(buildJob.id()); - buildJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO("", "", "")); + buildJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO("", "", ""), null); log.info("Adding build job back to the queue: {}", buildJob); queue.add(buildJob); localProcessingJobs.decrementAndGet(); @@ -295,7 +295,10 @@ private BuildJobQueueItem addToProcessingJobs() { if (buildJob != null) { String hazelcastMemberAddress = hazelcastInstance.getCluster().getLocalMember().getAddress().toString(); - BuildJobQueueItem processingJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO(buildAgentShortName, hazelcastMemberAddress, buildAgentDisplayName)); + var estimatedDuration = Math.max(0, buildJob.jobTimingInfo().estimatedDuration()); + ZonedDateTime estimatedCompletionDate = ZonedDateTime.now().plusSeconds(estimatedDuration); + BuildJobQueueItem processingJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO(buildAgentShortName, hazelcastMemberAddress, buildAgentDisplayName), + estimatedCompletionDate); processingJobs.put(processingJob.id(), processingJob); localProcessingJobs.incrementAndGet(); @@ -391,7 +394,8 @@ private void processBuild(BuildJobQueueItem buildJob) { futureResult.thenAccept(buildResult -> { log.debug("Build job completed: {}", buildJob); - JobTimingInfo jobTimingInfo = new JobTimingInfo(buildJob.jobTimingInfo().submissionDate(), buildJob.jobTimingInfo().buildStartDate(), ZonedDateTime.now()); + JobTimingInfo jobTimingInfo = new JobTimingInfo(buildJob.jobTimingInfo().submissionDate(), buildJob.jobTimingInfo().buildStartDate(), ZonedDateTime.now(), + buildJob.jobTimingInfo().estimatedCompletionDate(), buildJob.jobTimingInfo().estimatedDuration()); BuildJobQueueItem finishedJob = new BuildJobQueueItem(buildJob.id(), buildJob.name(), buildJob.buildAgent(), buildJob.participationId(), buildJob.courseId(), buildJob.exerciseId(), buildJob.retryCount(), buildJob.priority(), BuildStatus.SUCCESSFUL, buildJob.repositoryInfo(), jobTimingInfo, buildJob.buildConfig(), diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java index 6ddd70dad841..18256bb38921 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java @@ -64,6 +64,10 @@ public final class Constants { public static final String NEW_SUBMISSION_TOPIC = "/topic" + PROGRAMMING_SUBMISSION_TOPIC; + public static final String SUBMISSION_PROCESSING = "/submissionProcessing"; + + public static final String SUBMISSION_PROCESSING_TOPIC = "/topic" + SUBMISSION_PROCESSING; + public static final String ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH = "/api/public/athena/programming-exercises/"; // short names should have at least 3 characters and must start with a letter diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/dto/SubmissionDTO.java b/src/main/java/de/tum/cit/aet/artemis/exercise/dto/SubmissionDTO.java index 8abb58b3bd3a..93798b78d645 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/dto/SubmissionDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/dto/SubmissionDTO.java @@ -15,7 +15,8 @@ */ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record SubmissionDTO(Long id, Boolean submitted, SubmissionType type, Boolean exampleSubmission, ZonedDateTime submissionDate, String commitHash, Boolean buildFailed, - Boolean buildArtifact, ParticipationDTO participation, String submissionExerciseType) implements Serializable { + Boolean buildArtifact, ParticipationDTO participation, String submissionExerciseType, boolean isProcessing, ZonedDateTime buildStartDate, + ZonedDateTime estimatedCompletionDate) implements Serializable { /** * Converts a Submission into a SubmissionDTO. @@ -23,15 +24,16 @@ public record SubmissionDTO(Long id, Boolean submitted, SubmissionType type, Boo * @param submission to convert * @return the converted DTO */ - public static SubmissionDTO of(Submission submission) { + public static SubmissionDTO of(Submission submission, boolean isProcessing, ZonedDateTime buildStartDate, ZonedDateTime estimatedCompletionDate) { if (submission instanceof ProgrammingSubmission programmingSubmission) { // For programming submissions we need to extract additional information (e.g. the commit hash) and send it to the client return new SubmissionDTO(programmingSubmission.getId(), programmingSubmission.isSubmitted(), programmingSubmission.getType(), programmingSubmission.isExampleSubmission(), programmingSubmission.getSubmissionDate(), programmingSubmission.getCommitHash(), programmingSubmission.isBuildFailed(), programmingSubmission.isBuildArtifact(), ParticipationDTO.of(programmingSubmission.getParticipation()), - programmingSubmission.getSubmissionExerciseType()); + programmingSubmission.getSubmissionExerciseType(), isProcessing, buildStartDate, estimatedCompletionDate); } return new SubmissionDTO(submission.getId(), submission.isSubmitted(), submission.getType(), submission.isExampleSubmission(), submission.getSubmissionDate(), null, null, - null, ParticipationDTO.of(submission.getParticipation()), submission.getSubmissionExerciseType()); + null, ParticipationDTO.of(submission.getParticipation()), submission.getSubmissionExerciseType(), false, null, null); } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java index a5ada6708999..9adf3ac4bab4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java @@ -85,6 +85,12 @@ public class ProgrammingExerciseBuildConfig extends DomainObject { @Column(name = "build_plan_access_secret", length = 36) private String buildPlanAccessSecret; + @Column(name = "build_duration_seconds") + private long buildDurationSeconds = 0; + + @Column(name = "successful_build_count") + private long successfulBuildCount = 0; + public ProgrammingExerciseBuildConfig() { } @@ -104,6 +110,8 @@ public ProgrammingExerciseBuildConfig(ProgrammingExerciseBuildConfig originalBui this.setAllowBranching(originalBuildConfig.isAllowBranching()); this.setBranchRegex(originalBuildConfig.getBranchRegex()); this.setProgrammingExercise(null); + this.setBuildDurationSeconds(0); + this.setSuccessfulBuildCount(0); this.buildPlanAccessSecret = null; } @@ -292,6 +300,22 @@ public void setSolutionCheckoutPath(String solutionCheckoutPath) { this.solutionCheckoutPath = solutionCheckoutPath; } + public long getBuildDurationSeconds() { + return buildDurationSeconds; + } + + public void setBuildDurationSeconds(long buildDurationSeconds) { + this.buildDurationSeconds = buildDurationSeconds; + } + + public long getSuccessfulBuildCount() { + return successfulBuildCount; + } + + public void setSuccessfulBuildCount(long successfulBuildCount) { + this.successfulBuildCount = successfulBuildCount; + } + @Override public String toString() { return "BuildJobConfig{" + "id=" + getId() + ", sequentialTestRuns=" + sequentialTestRuns + ", branch='" + branch + '\'' + ", buildPlanConfiguration='" diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/ResultDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/ResultDTO.java index cfab7c3a3840..609bdd1d77f4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/dto/ResultDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/ResultDTO.java @@ -63,7 +63,7 @@ public static ResultDTO of(Result result) { public static ResultDTO of(Result result, List filteredFeedback) { SubmissionDTO submissionDTO = null; if (Hibernate.isInitialized(result.getSubmission()) && result.getSubmission() != null) { - submissionDTO = SubmissionDTO.of(result.getSubmission()); + submissionDTO = SubmissionDTO.of(result.getSubmission(), false, null, null); } var feedbackDTOs = filteredFeedback.stream().map(FeedbackDTO::of).toList(); return new ResultDTO(result.getId(), result.getCompletionDate(), result.isSuccessful(), result.getScore(), result.isRated(), submissionDTO, diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/SubmissionProcessingDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/SubmissionProcessingDTO.java new file mode 100644 index 000000000000..52a46dee197a --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/SubmissionProcessingDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.programming.dto; + +import java.io.Serial; +import java.io.Serializable; +import java.time.ZonedDateTime; + +public record SubmissionProcessingDTO(long exerciseId, long participationId, String commitHash, ZonedDateTime buildStartDate, ZonedDateTime estimatedCompletionDate) + implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java index dfb4e931ac27..58b63fe716e0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java @@ -6,6 +6,8 @@ import static de.tum.cit.aet.artemis.core.config.Constants.NEW_SUBMISSION_TOPIC; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static de.tum.cit.aet.artemis.core.config.Constants.PROGRAMMING_SUBMISSION_TOPIC; +import static de.tum.cit.aet.artemis.core.config.Constants.SUBMISSION_PROCESSING; +import static de.tum.cit.aet.artemis.core.config.Constants.SUBMISSION_PROCESSING_TOPIC; import static de.tum.cit.aet.artemis.core.config.Constants.TEST_CASES_CHANGED_RUN_COMPLETED_NOTIFICATION; import java.util.Optional; @@ -23,6 +25,7 @@ import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.dto.SubmissionDTO; +import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; import de.tum.cit.aet.artemis.lti.service.LtiNewResultService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -30,6 +33,7 @@ import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingSubmission; import de.tum.cit.aet.artemis.programming.domain.build.BuildRunState; +import de.tum.cit.aet.artemis.programming.dto.SubmissionProcessingDTO; import de.tum.cit.aet.artemis.programming.exception.BuildTriggerWebsocketError; @Profile(PROFILE_CORE) @@ -48,13 +52,17 @@ public class ProgrammingMessagingService { private final TeamRepository teamRepository; + private final ParticipationRepository participationRepository; + public ProgrammingMessagingService(GroupNotificationService groupNotificationService, WebsocketMessagingService websocketMessagingService, - ResultWebsocketService resultWebsocketService, Optional ltiNewResultService, TeamRepository teamRepository) { + ResultWebsocketService resultWebsocketService, Optional ltiNewResultService, TeamRepository teamRepository, + ParticipationRepository participationRepository) { this.groupNotificationService = groupNotificationService; this.websocketMessagingService = websocketMessagingService; this.resultWebsocketService = resultWebsocketService; this.ltiNewResultService = ltiNewResultService; this.teamRepository = teamRepository; + this.participationRepository = participationRepository; } public void notifyInstructorAboutStartedExerciseBuildRun(ProgrammingExercise programmingExercise) { @@ -76,7 +84,7 @@ public void notifyInstructorAboutCompletedExerciseBuildRun(ProgrammingExercise p * @param exerciseId used to build the correct topic */ public void notifyUserAboutSubmission(ProgrammingSubmission submission, Long exerciseId) { - var submissionDTO = SubmissionDTO.of(submission); + var submissionDTO = SubmissionDTO.of(submission, false, null, null); if (submission.getParticipation() instanceof StudentParticipation studentParticipation) { if (studentParticipation.getParticipant() instanceof Team team) { // eager load the team with students so their information can be used for the messages below @@ -148,6 +156,10 @@ private static String getExerciseTopicForTAAndAbove(long exerciseId) { return EXERCISE_TOPIC_ROOT + exerciseId + PROGRAMMING_SUBMISSION_TOPIC; } + private static String getSubmissionProcessingTopicForTAAndAbove(Long exerciseId) { + return EXERCISE_TOPIC_ROOT + exerciseId + SUBMISSION_PROCESSING; + } + public static String getProgrammingExerciseTestCaseChangedTopic(Long programmingExerciseId) { return "/topic/programming-exercises/" + programmingExerciseId + "/test-cases-changed"; } @@ -172,4 +184,30 @@ public void notifyUserAboutNewResult(Result result, ProgrammingExerciseParticipa ltiNewResultService.get().onNewResult(studentParticipation); } } + + /** + * Notifies the user about the processing of a submission. + * This method sends a notification to the user that their submission is processing + * It handles both student participations and template/solution participations. + * + * @param submission the submission processing data transfer object containing the submission details + * @param exerciseId the ID of the exercise associated with the submission + * @param participationId the ID of the participation associated with the submission + */ + public void notifyUserAboutSubmissionProcessing(SubmissionProcessingDTO submission, long exerciseId, long participationId) { + Participation participation = participationRepository.findWithProgrammingExerciseWithBuildConfigById(participationId).orElseThrow(); + if (participation instanceof StudentParticipation studentParticipation) { + if (studentParticipation.getParticipant() instanceof Team team) { + // eager load the team with students so their information can be used for the messages below + studentParticipation.setParticipant(teamRepository.findWithStudentsByIdElseThrow(team.getId())); + } + studentParticipation.getStudents().forEach(user -> websocketMessagingService.sendMessageToUser(user.getLogin(), SUBMISSION_PROCESSING_TOPIC, submission)); + } + + // send an update to tutors, editors and instructors about submissions for template and solution participations + if (!(participation instanceof StudentParticipation)) { + var topicDestination = getSubmissionProcessingTopicForTAAndAbove(exerciseId); + websocketMessagingService.sendMessage(topicDestination, submission); + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java index 5a805ff54d03..2a7048885dfc 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.programming.service.localci; +import java.time.ZonedDateTime; import java.util.List; import jakarta.annotation.PostConstruct; @@ -21,6 +22,8 @@ import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; +import de.tum.cit.aet.artemis.programming.dto.SubmissionProcessingDTO; +import de.tum.cit.aet.artemis.programming.service.ProgrammingMessagingService; /** * This service is responsible for sending build job queue information over websockets. @@ -36,6 +39,8 @@ public class LocalCIQueueWebsocketService { private final LocalCIWebsocketMessagingService localCIWebsocketMessagingService; + private final ProgrammingMessagingService programmingMessagingService; + private final SharedQueueManagementService sharedQueueManagementService; private final HazelcastInstance hazelcastInstance; @@ -48,10 +53,11 @@ public class LocalCIQueueWebsocketService { * @param sharedQueueManagementService the local ci shared build job queue service */ public LocalCIQueueWebsocketService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, LocalCIWebsocketMessagingService localCIWebsocketMessagingService, - SharedQueueManagementService sharedQueueManagementService) { + SharedQueueManagementService sharedQueueManagementService, ProgrammingMessagingService programmingMessagingService) { this.hazelcastInstance = hazelcastInstance; this.localCIWebsocketMessagingService = localCIWebsocketMessagingService; this.sharedQueueManagementService = sharedQueueManagementService; + this.programmingMessagingService = programmingMessagingService; } /** @@ -112,6 +118,8 @@ private class ProcessingBuildJobItemListener implements EntryAddedListener event) { log.debug("CIBuildJobQueueItem added to processing jobs: {}", event.getValue()); sendProcessingJobsOverWebsocket(event.getValue().courseId()); + notifyUserAboutBuildProcessing(event.getValue().exerciseId(), event.getValue().participationId(), event.getValue().buildConfig().assignmentCommitHash(), + event.getValue().jobTimingInfo().buildStartDate(), event.getValue().jobTimingInfo().estimatedCompletionDate()); } @Override @@ -142,4 +150,9 @@ public void entryUpdated(com.hazelcast.core.EntryEvent { @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java index cb4e894c90f7..e2560a847bf0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java @@ -101,6 +101,10 @@ public class LocalCITriggerService implements ContinuousIntegrationTriggerServic private final ExerciseDateService exerciseDateService; + private static final int DEFAULT_BUILD_DURATION = 17; + + private static final double BUILD_DURATION_SAFETY_FACTOR = 1.2; + public LocalCITriggerService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, AeolusTemplateService aeolusTemplateService, ProgrammingLanguageConfiguration programmingLanguageConfiguration, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, LocalCIProgrammingLanguageFeatureService programmingLanguageFeatureService, Optional versionControlService, @@ -189,10 +193,13 @@ else if (triggeredByPushTo.equals(RepositoryType.TESTS)) { String buildJobId = String.valueOf(participation.getId()) + submissionDate.toInstant().toEpochMilli(); - JobTimingInfo jobTimingInfo = new JobTimingInfo(submissionDate, null, null); - var programmingExerciseBuildConfig = loadBuildConfig(programmingExercise); + long estimatedDuration = programmingExerciseBuildConfig.getBuildDurationSeconds() == 0 ? DEFAULT_BUILD_DURATION : programmingExerciseBuildConfig.getBuildDurationSeconds(); + estimatedDuration = Math.round(estimatedDuration * BUILD_DURATION_SAFETY_FACTOR); + + JobTimingInfo jobTimingInfo = new JobTimingInfo(submissionDate, null, null, null, estimatedDuration); + RepositoryInfo repositoryInfo = getRepositoryInfo(participation, triggeredByPushTo, programmingExerciseBuildConfig); BuildConfig buildConfig = getBuildConfig(participation, commitHashToBuild, assignmentCommitHash, testCommitHash, programmingExerciseBuildConfig); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java index 87b44d4872ba..744bbe6773f3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java @@ -5,9 +5,11 @@ import java.time.Duration; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.PriorityQueue; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -28,6 +30,9 @@ import com.hazelcast.collection.IQueue; import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; +import com.hazelcast.map.listener.EntryAddedListener; +import com.hazelcast.map.listener.EntryRemovedListener; +import com.hazelcast.map.listener.EntryUpdatedListener; import com.hazelcast.topic.ITopic; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; @@ -71,6 +76,10 @@ public class SharedQueueManagementService { private ITopic resumeBuildAgentTopic; + private int buildAgentsCapacity; + + private int runningBuildJobCount; + public SharedQueueManagementService(BuildJobRepository buildJobRepository, @Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ProfileService profileService) { this.buildJobRepository = buildJobRepository; this.hazelcastInstance = hazelcastInstance; @@ -89,6 +98,8 @@ public void init() { this.dockerImageCleanupInfo = this.hazelcastInstance.getMap("dockerImageCleanupInfo"); this.pauseBuildAgentTopic = hazelcastInstance.getTopic("pauseBuildAgentTopic"); this.resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); + this.buildAgentInformation.addEntryListener(new BuildAgentListener(), false); + this.getBuildAgentsCapacity(); } /** @@ -289,4 +300,125 @@ public Page getFilteredFinishedBuildJobs(FinishedBuildJobPageableSearc return new PageImpl<>(orderedBuildJobs, buildJobIdsPage.getPageable(), buildJobIdsPage.getTotalElements()); } + /** + * Estimates the queue release date for a build job based on the participation ID. + * This method calculates the estimated queue duration for a buildjob with the given participation ID. + * It takes into account the current queue state, the capacity of build agents, + * and the remaining duration of currently processing jobs. + * + * @param participationId the ID of the participation for which the queue release date is estimated + * @return the estimated queue release date as a {@link ZonedDateTime} + */ + public ZonedDateTime getBuildJobEstimatedQueueDuration(long participationId) { + if (queue.isEmpty() || this.buildAgentsCapacity > this.runningBuildJobCount + queue.size()) { + return ZonedDateTime.now(); + } + + String buildJobId = getIdOfQueuedJobFromParticipation(participationId); + + if (buildJobId == null) { + return ZonedDateTime.now(); + } + + List jobsQueuedBefore = queue.stream().sorted(new LocalCIPriorityQueueComparator()).takeWhile(job -> !job.id().equals(buildJobId)).toList(); + + ZonedDateTime now = ZonedDateTime.now(); + + List agentsAvailabilities = new ArrayList<>(processingJobs.values().stream().map(job -> buildJobRemainingDuration(job, now)).sorted().toList()); + + if (agentsAvailabilities.size() < this.buildAgentsCapacity) { + int agentsToAdd = this.buildAgentsCapacity - agentsAvailabilities.size(); + agentsAvailabilities.addAll(Collections.nCopies(agentsToAdd, 0L)); + } + else { + agentsAvailabilities = agentsAvailabilities.subList(0, this.buildAgentsCapacity); + log.warn("There are more processing jobs than the build agents' capacity. This should not happen. Processing jobs: {}, Build agents: {}", processingJobs, + buildAgentInformation); + } + + if (jobsQueuedBefore.size() < agentsAvailabilities.size()) { + return now.plusSeconds(agentsAvailabilities.get(jobsQueuedBefore.size())); + } + else { + return now.plusSeconds(calculateNextJobQueueDuration(agentsAvailabilities, jobsQueuedBefore)); + } + } + + private String getIdOfQueuedJobFromParticipation(long participationId) { + var participationBuildJobIds = queue.stream().filter(job -> job.participationId() == participationId).map(BuildJobQueueItem::id).toList(); + if (participationBuildJobIds.isEmpty()) { + return null; + } + return participationBuildJobIds.getLast(); + } + + private Long calculateNextJobQueueDuration(List agentsAvailabilities, List jobsQueuedBefore) { + PriorityQueue agentAvailabilitiesQueue = new PriorityQueue<>(agentsAvailabilities); + for (BuildJobQueueItem job : jobsQueuedBefore) { + Long agentRemainingTimeObj = agentAvailabilitiesQueue.poll(); + long agentRemainingTime = agentRemainingTimeObj == null ? 0 : agentRemainingTimeObj; + agentRemainingTime = Math.max(0, agentRemainingTime); + agentAvailabilitiesQueue.add(agentRemainingTime + job.jobTimingInfo().estimatedDuration()); + } + Long agentRemainingTimeObj = agentAvailabilitiesQueue.poll(); + return agentRemainingTimeObj == null ? 0 : agentRemainingTimeObj; + } + + private long buildJobRemainingDuration(BuildJobQueueItem buildJob, ZonedDateTime now) { + ZonedDateTime estimatedCompletionDate = buildJob.jobTimingInfo().estimatedCompletionDate(); + if (estimatedCompletionDate == null) { + return 0; + } + if (estimatedCompletionDate.isBefore(now)) { + return 0; + } + return Duration.between(now, estimatedCompletionDate).toSeconds(); + + } + + private class BuildAgentListener + implements EntryAddedListener, EntryRemovedListener, EntryUpdatedListener { + + @Override + public void entryAdded(com.hazelcast.core.EntryEvent event) { + log.debug("Build agent added: {}", event.getValue()); + getBuildAgentsCapacity(); + } + + @Override + public void entryRemoved(com.hazelcast.core.EntryEvent event) { + log.debug("Build agent removed: {}", event.getOldValue()); + getBuildAgentsCapacity(); + } + + @Override + public void entryUpdated(com.hazelcast.core.EntryEvent event) { + log.debug("Build agent updated: {}", event.getValue()); + getBuildAgentsCapacity(); + } + } + + private void getBuildAgentsCapacity() { + buildAgentsCapacity = buildAgentInformation.values().stream().mapToInt(BuildAgentInformation::maxNumberOfConcurrentBuildJobs).sum(); + runningBuildJobCount = buildAgentInformation.values().stream().mapToInt(BuildAgentInformation::numberOfCurrentBuildJobs).sum(); + } + + /** + * Check if a submission is currently being processed. + * + * @param participationId the id of the participation + * @param commitHash the commit hash + * @return the build start date and estimated completion date of the submission if it is currently being processed, null otherwise + */ + public BuildTimingInfo isSubmissionProcessing(long participationId, String commitHash) { + var buildJob = processingJobs.values().stream() + .filter(job -> job.participationId() == participationId && Objects.equals(commitHash, job.buildConfig().assignmentCommitHash())).findFirst(); + if (buildJob.isPresent()) { + return new BuildTimingInfo(buildJob.get().jobTimingInfo().buildStartDate(), buildJob.get().jobTimingInfo().estimatedCompletionDate()); + } + return null; + } + + public record BuildTimingInfo(ZonedDateTime buildStartDate, ZonedDateTime estimatedCompletionDate) { + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index be1c99c67be6..98723ce108ab 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -38,6 +38,7 @@ import de.tum.cit.aet.artemis.exam.repository.StudentExamRepository; import de.tum.cit.aet.artemis.exam.service.ExamService; import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; +import de.tum.cit.aet.artemis.exercise.dto.SubmissionDTO; import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; import de.tum.cit.aet.artemis.exercise.service.ParticipationAuthorizationCheckService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -56,6 +57,7 @@ import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseParticipationService; import de.tum.cit.aet.artemis.programming.service.ProgrammingSubmissionService; import de.tum.cit.aet.artemis.programming.service.RepositoryService; +import de.tum.cit.aet.artemis.programming.service.localci.SharedQueueManagementService; @Profile(PROFILE_CORE) @RestController @@ -92,11 +94,14 @@ public class ProgrammingExerciseParticipationResource { private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + private final Optional sharedQueueManagementService; + public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipationService programmingExerciseParticipationService, ResultRepository resultRepository, ParticipationRepository participationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProgrammingSubmissionService submissionService, ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, ResultService resultService, ParticipationAuthorizationCheckService participationAuthCheckService, RepositoryService repositoryService, - StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { + StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, + Optional sharedQueueManagementService) { this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.participationRepository = participationRepository; this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; @@ -110,6 +115,7 @@ public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipation this.studentExamRepository = studentExamRepository; this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; this.vcsAccessLogRepository = vcsAccessLogRepository; + this.sharedQueueManagementService = sharedQueueManagementService; } /** @@ -208,7 +214,7 @@ public ResponseEntity checkIfParticipationHashResult(@PathVariable Long */ @GetMapping("programming-exercise-participations/{participationId}/latest-pending-submission") @EnforceAtLeastStudent - public ResponseEntity getLatestPendingSubmission(@PathVariable Long participationId, @RequestParam(defaultValue = "false") boolean lastGraded) { + public ResponseEntity getLatestPendingSubmission(@PathVariable Long participationId, @RequestParam(defaultValue = "false") boolean lastGraded) { Optional submissionOpt; try { submissionOpt = submissionService.getLatestPendingSubmission(participationId, lastGraded); @@ -216,9 +222,31 @@ public ResponseEntity getLatestPendingSubmission(@PathVar catch (IllegalArgumentException ex) { throw new EntityNotFoundException("participation", participationId); } + if (submissionOpt.isEmpty()) { + return ResponseEntity.ok(null); + } + ProgrammingSubmission programmingSubmission = submissionOpt.get(); + boolean isSubmissionProcessing = false; + ZonedDateTime buildStartDate = null; + ZonedDateTime estimatedCompletionDate = null; + if (sharedQueueManagementService.isPresent()) { + try { + var buildTimingInfo = sharedQueueManagementService.get().isSubmissionProcessing(participationId, programmingSubmission.getCommitHash()); + if (buildTimingInfo != null) { + isSubmissionProcessing = true; + buildStartDate = buildTimingInfo.buildStartDate(); + estimatedCompletionDate = buildTimingInfo.estimatedCompletionDate(); + } + } + catch (Exception e) { + log.warn("Failed to get build timing info for submission {} of participation {}: {}", programmingSubmission.getCommitHash(), participationId, e.getMessage()); + } + } + // Remove participation, is not needed in the response. - submissionOpt.ifPresent(submission -> submission.setParticipation(null)); - return ResponseEntity.ok(submissionOpt.orElse(null)); + programmingSubmission.setParticipation(null); + var submissionDTO = SubmissionDTO.of(programmingSubmission, isSubmissionProcessing, buildStartDate, estimatedCompletionDate); + return ResponseEntity.ok(submissionDTO); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildJobQueueResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildJobQueueResource.java index 1243f455563a..bb5d8f3b9f5f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildJobQueueResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildJobQueueResource.java @@ -29,8 +29,10 @@ import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.util.TimeLogUtil; import de.tum.cit.aet.artemis.programming.domain.build.BuildJob; import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; import de.tum.cit.aet.artemis.programming.service.localci.SharedQueueManagementService; @@ -189,4 +191,23 @@ public ResponseEntity getBuildJobStatistics(@PathVariabl BuildJobsStatisticsDTO buildJobStatistics = BuildJobsStatisticsDTO.of(buildJobResultCountDtos); return ResponseEntity.ok(buildJobStatistics); } + + /** + * Returns the estimated queue duration for a build job. + * + * @param participationId the id of the participation + * @return the estimated queue duration + */ + @GetMapping("queued-jobs/queue-duration-estimation") + @EnforceAtLeastStudent + public ResponseEntity getBuildJobQueueDurationEstimation(@RequestParam long participationId) { + var start = System.nanoTime(); + if (participationId <= 0) { + ResponseEntity.badRequest().build(); + } + var estimatedQueueDuration = localCIBuildJobQueueService.getBuildJobEstimatedQueueDuration(participationId); + log.debug("Queue duration estimation took {} ms", TimeLogUtil.formatDurationFrom(start)); + return ResponseEntity.ok(estimatedQueueDuration); + } + } diff --git a/src/main/resources/config/liquibase/changelog/20241101120000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241101120000_changelog.xml new file mode 100644 index 000000000000..808bf24920f7 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241101120000_changelog.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 6a06b398b783..3556b20b0e11 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -33,6 +33,7 @@ + diff --git a/src/main/webapp/app/entities/programming/programming-submission.model.ts b/src/main/webapp/app/entities/programming/programming-submission.model.ts index af5176513791..2e3ab53da56f 100644 --- a/src/main/webapp/app/entities/programming/programming-submission.model.ts +++ b/src/main/webapp/app/entities/programming/programming-submission.model.ts @@ -6,6 +6,9 @@ export class ProgrammingSubmission extends Submission { public commitHash?: string; public buildFailed?: boolean; public buildArtifact?: boolean; // whether the result includes a build artifact or not + public isProcessing?: boolean; + public buildStartDate?: dayjs.Dayjs; + public estimatedCompletionDate?: dayjs.Dayjs; constructor() { super(SubmissionExerciseType.PROGRAMMING); diff --git a/src/main/webapp/app/entities/programming/submission-processing-dto.ts b/src/main/webapp/app/entities/programming/submission-processing-dto.ts new file mode 100644 index 000000000000..fa7fd0e56ad2 --- /dev/null +++ b/src/main/webapp/app/entities/programming/submission-processing-dto.ts @@ -0,0 +1,9 @@ +import dayjs from 'dayjs/esm'; + +export class SubmissionProcessingDTO { + public exerciseId?: number; + public participationId?: number; + public commitHash?: string; + public estimatedCompletionDate?: dayjs.Dayjs; + public buildStartDate?: dayjs.Dayjs; +} diff --git a/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.html b/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.html index 5fb244524421..5619d06ad10e 100644 --- a/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.html +++ b/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.html @@ -75,6 +75,7 @@ [participation]="participation" [personalParticipation]="true" (onParticipationChange)="receivedNewResult()" + [showProgressBar]="true" class="me-2" /> } diff --git a/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts b/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts index 0fd3d43a0535..391ac3516006 100644 --- a/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts +++ b/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts @@ -13,6 +13,10 @@ import { ProgrammingExerciseStudentParticipation } from 'app/entities/participat import { findLatestResult } from 'app/shared/util/utils'; import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; +import { SubmissionProcessingDTO } from 'app/entities/programming/submission-processing-dto'; +import dayjs from 'dayjs/esm'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_LOCALCI } from 'app/app.constants'; export enum ProgrammingSubmissionState { // The last submission of participation has a result. @@ -21,9 +25,21 @@ export enum ProgrammingSubmissionState { IS_BUILDING_PENDING_SUBMISSION = 'IS_BUILDING_PENDING_SUBMISSION', // A failed submission is a pending submission that has not received a result within an expected time frame. HAS_FAILED_SUBMISSION = 'HAS_FAILED_SUBMISSION', + // The submission is queued and will be built soon. + IS_QUEUED = 'IS_QUEUED', } -export type ProgrammingSubmissionStateObj = { participationId: number; submissionState: ProgrammingSubmissionState; submission?: ProgrammingSubmission }; +export type ProgrammingSubmissionStateObj = { + participationId: number; + submissionState: ProgrammingSubmissionState; + submission?: ProgrammingSubmission; + buildTimingInfo?: BuildTimingInfo; +}; + +export type BuildTimingInfo = { + estimatedCompletionDate?: dayjs.Dayjs; + buildStartDate?: dayjs.Dayjs; +}; export type ExerciseSubmissionState = { [participationId: number]: ProgrammingSubmissionStateObj }; @@ -54,11 +70,17 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi public PROGRAMMING_EXERCISE_RESOURCE_URL = 'api/programming-exercises/'; // Default value: 2 minutes. private DEFAULT_EXPECTED_RESULT_ETA = 2 * 60 * 1000; + // Default value: 60 seconds. + private DEFAULT_EXPECTED_QUEUE_ESTIMATE = 60 * 1000; private SUBMISSION_TEMPLATE_TOPIC = '/topic/exercise/%exerciseId%/newSubmissions'; + private SUBMISSION_PROCESSING_TEMPLATE_TOPIC = '/topic/exercise/%exerciseId%/submissionProcessing'; + private resultSubscriptions: { [participationId: number]: Subscription } = {}; // participationId -> topic private submissionTopicsSubscribed = new Map(); + // participationId -> topic + private submissionProcessingTopicsSubscribed = new Map(); // participationId -> exerciseId private participationIdToExerciseId = new Map(); @@ -72,20 +94,33 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi private resultTimerSubscriptions: { [participationId: number]: Subscription } = {}; private resultEtaSubject = new BehaviorSubject(this.DEFAULT_EXPECTED_RESULT_ETA); + private queueEstimateTimerSubscriptions: { [participationId: number]: Subscription } = {}; + private exerciseBuildStateValue: { [exerciseId: number]: ExerciseSubmissionState } = {}; private currentExpectedResultETA = this.DEFAULT_EXPECTED_RESULT_ETA; + private currentExpectedQueueEstimate = this.DEFAULT_EXPECTED_QUEUE_ESTIMATE; + + private startedProcessingCache: Map = new Map(); + private isLocalCIProfile?: boolean = undefined; constructor( private websocketService: JhiWebsocketService, private http: HttpClient, private participationWebsocketService: ParticipationWebsocketService, private participationService: ProgrammingExerciseParticipationService, - ) {} + private profileService: ProfileService, + ) { + this.profileService.getProfileInfo().subscribe((profileInfo) => { + this.setLocalCIProfile(!!profileInfo?.activeProfiles.includes(PROFILE_LOCALCI)); + }); + } ngOnDestroy(): void { Object.values(this.resultSubscriptions).forEach((sub) => sub.unsubscribe()); Object.values(this.resultTimerSubscriptions).forEach((sub) => sub.unsubscribe()); + Object.values(this.queueEstimateTimerSubscriptions).forEach((sub) => sub.unsubscribe()); this.submissionTopicsSubscribed.forEach((topic) => this.websocketService.unsubscribe(topic)); + this.submissionProcessingTopicsSubscribed.forEach((topic) => this.websocketService.unsubscribe(topic)); } get exerciseBuildState() { @@ -146,6 +181,10 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi .pipe(catchError(() => of([]))); } + public fetchQueueReleaseDateEstimationByParticipationId(participationId: number): Observable { + return this.http.get('api/queued-jobs/queue-duration-estimation', { params: { participationId } }).pipe(catchError(() => of(undefined))); + } + /** * Start a timer after which the timer subject will notify the corresponding subject. * Side effect: Timer will also emit an alert when the time runs out as it means here that no result came for a submission. @@ -173,6 +212,26 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi } } + private startQueueEstimateTimer(participationId: number, exerciseId: number, submission: ProgrammingSubmission, time = this.currentExpectedQueueEstimate) { + this.resetQueueEstimateTimer(participationId); + this.queueEstimateTimerSubscriptions[participationId] = timer(time).subscribe(() => { + const remainingTime = this.getExpectedRemainingTimeForBuild(submission); + if (remainingTime > 0) { + this.emitBuildingSubmission(participationId, exerciseId, submission); + this.startResultWaitingTimer(participationId, remainingTime); + } else { + this.emitFailedSubmission(participationId, exerciseId); + } + this.resetQueueEstimateTimer(participationId); + }); + } + + private resetQueueEstimateTimer(participationId: number) { + if (this.queueEstimateTimerSubscriptions[participationId]) { + this.queueEstimateTimerSubscriptions[participationId].unsubscribe(); + } + } + /** * Set up a submission subscription for the latest pending submission if not yet existing. * @@ -206,7 +265,32 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi } const programmingSubmission = submission as ProgrammingSubmission; const submissionParticipationId = programmingSubmission.participation!.id!; - this.emitBuildingSubmission(submissionParticipationId, this.participationIdToExerciseId.get(submissionParticipationId)!, submission); + let buildTimingInfo: BuildTimingInfo | undefined = undefined; + + if (this.isLocalCIProfile) { + if (!programmingSubmission.isProcessing && !this.didSubmissionStartProcessing(programmingSubmission.commitHash!)) { + const queueRemainingTime = this.getExpectedRemainingTimeForQueue(programmingSubmission); + if (queueRemainingTime > 0) { + this.emitQueuedSubmission( + submissionParticipationId, + this.participationIdToExerciseId.get(submissionParticipationId)!, + programmingSubmission, + ); + this.startQueueEstimateTimer( + submissionParticipationId, + this.participationIdToExerciseId.get(submissionParticipationId)!, + programmingSubmission, + queueRemainingTime, + ); + return; + } + } + + buildTimingInfo = this.startedProcessingCache.get(programmingSubmission.commitHash!); + this.removeSubmissionFromProcessingCache(programmingSubmission.commitHash!); + } + + this.emitBuildingSubmission(submissionParticipationId, this.participationIdToExerciseId.get(submissionParticipationId)!, submission, buildTimingInfo); // Now we start a timer, if there is no result when the timer runs out, it will notify the subscribers that no result was received and show an error. this.startResultWaitingTimer(submissionParticipationId); }), @@ -217,6 +301,78 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi } } + private setupWebsocketSubscriptionForSubmissionProcessing(participationId: number, exerciseId: number, personal: boolean): void { + if (!this.submissionProcessingTopicsSubscribed.get(participationId)) { + let newSubmissionTopic: string; + if (personal) { + newSubmissionTopic = '/user/topic/submissionProcessing'; + } else { + newSubmissionTopic = this.SUBMISSION_PROCESSING_TEMPLATE_TOPIC.replace('%exerciseId%', exerciseId.toString()); + } + + // Only subscribe if not subscription to same topic exists (e.g. from different participation) + if (!Array.from(this.submissionProcessingTopicsSubscribed.values()).includes(newSubmissionTopic)) { + this.websocketService.subscribe(newSubmissionTopic); + this.websocketService + .receive(newSubmissionTopic) + .pipe( + tap((submissionProcessing: SubmissionProcessingDTO) => { + const programmingSubmission = this.getSubmissionByCommitHash(submissionProcessing); + // It is possible that the submission started processing before it got saved to the database and the message was sent to the client. + // In this case, we cache that the submission started processing and do not emit the building state. + // When the submission message arrives, we check if the submission is already in the cache. + if (!programmingSubmission) { + this.startedProcessingCache.set(submissionProcessing.commitHash!, { + estimatedCompletionDate: submissionProcessing.estimatedCompletionDate, + buildStartDate: submissionProcessing.buildStartDate, + }); + return; + } + programmingSubmission.isProcessing = true; + const submissionParticipationId = submissionProcessing.participationId!; + const exerciseId = this.participationIdToExerciseId.get(submissionParticipationId)!; + + if (!this.isNewestSubmission(programmingSubmission, exerciseId, submissionParticipationId)) { + this.removeSubmissionFromProcessingCache(programmingSubmission.commitHash!); + return; + } + + const buildTimingInfo = { + estimatedCompletionDate: submissionProcessing.estimatedCompletionDate, + buildStartDate: submissionProcessing.buildStartDate, + }; + this.removeSubmissionFromProcessingCache(programmingSubmission.commitHash!); + this.resetQueueEstimateTimer(submissionParticipationId); + this.emitBuildingSubmission(submissionParticipationId, exerciseId, programmingSubmission, buildTimingInfo); + // Now we start a timer, if there is no result when the timer runs out, it will notify the subscribers that no result was received and show an error. + this.startResultWaitingTimer(submissionParticipationId); + }), + ) + .subscribe(); + } + this.submissionProcessingTopicsSubscribed.set(participationId, newSubmissionTopic); + } + } + + private isNewestSubmission(newSubmission: ProgrammingSubmission, exerciseId: number, participationId: number): boolean { + const currentSubmission = this.exerciseBuildState[exerciseId]?.[participationId]?.submission; + + if (!currentSubmission?.id) return true; + if (!newSubmission?.id) return false; + + return newSubmission.id >= currentSubmission.id; + } + + private getSubmissionByCommitHash(submissionProcessing: SubmissionProcessingDTO): ProgrammingSubmission | undefined { + if (submissionProcessing.exerciseId && submissionProcessing.participationId && submissionProcessing.commitHash) { + const submission = this.exerciseBuildState[submissionProcessing.exerciseId]?.[submissionProcessing.participationId]?.submission; + if (submission && submission.commitHash === submissionProcessing.commitHash) { + return submission; + } + } + return undefined; + } + /** * Waits for a new result to come in while a pending submission exists. * Will stop waiting after the timer subject has emitted a value. @@ -264,6 +420,7 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi filter(() => !!this.exerciseBuildState[exerciseId][participationId]), tap(() => { // We reset the timer when a new result came through OR the timer ran out. The stream will then be inactive until the next submission comes in. + this.resetQueueEstimateTimer(participationId); this.resetResultWaitingTimer(participationId); }), ) @@ -283,8 +440,13 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi this.notifySubscribers(participationId, exerciseId, newSubmissionState); } - private emitBuildingSubmission(participationId: number, exerciseId: number, submission: ProgrammingSubmission) { - const newSubmissionState = { participationId, submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission }; + private emitBuildingSubmission(participationId: number, exerciseId: number, submission: ProgrammingSubmission, buildTimingInfo?: BuildTimingInfo) { + const newSubmissionState = { participationId, submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission, buildTimingInfo }; + this.notifySubscribers(participationId, exerciseId, newSubmissionState); + } + + private emitQueuedSubmission(participationId: number, exerciseId: number, submission: ProgrammingSubmission) { + const newSubmissionState = { participationId, submissionState: ProgrammingSubmissionState.IS_QUEUED, submission }; this.notifySubscribers(participationId, exerciseId, newSubmissionState); } @@ -329,6 +491,10 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi return this.currentExpectedResultETA - (Date.now() - Date.parse(submission.submissionDate as any)); } + private getExpectedRemainingTimeForQueue(submission: ProgrammingSubmission): number { + return this.currentExpectedQueueEstimate - (Date.now() - Date.parse(submission.submissionDate as any)); + } + /** * Initialize the cache from outside the service. * @@ -537,15 +703,37 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi // The new submission would then override the current latest pending submission. tap(() => { this.setupWebsocketSubscriptionForLatestPendingSubmission(participationId, exerciseId, personal); + if (this.isLocalCIProfile) { + this.setupWebsocketSubscriptionForSubmissionProcessing(participationId, exerciseId, personal); + } }), // Find out in what state the latest submission is (pending / failed). If the submission is pending, start the result timer. map((submission: ProgrammingSubmission | undefined) => { if (submission) { - const remainingTime = this.getExpectedRemainingTimeForBuild(submission); - if (remainingTime > 0) { - this.emitBuildingSubmission(participationId, exerciseId, submission); - this.startResultWaitingTimer(participationId, remainingTime); - return { participationId, submission: submissionToBeProcessed, submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION }; + if (this.isLocalCIProfile && submission.isProcessing === false && !this.didSubmissionStartProcessing(submission.commitHash!)) { + const queueRemainingTime = this.getExpectedRemainingTimeForQueue(submission); + if (queueRemainingTime > 0) { + this.emitQueuedSubmission(participationId, exerciseId, submission); + this.startQueueEstimateTimer(participationId, exerciseId, submission, queueRemainingTime); + return { + participationId, + submission: submissionToBeProcessed, + submissionState: ProgrammingSubmissionState.IS_QUEUED, + }; + } + } else { + let buildTimingInfo: BuildTimingInfo | undefined = { + estimatedCompletionDate: submission.estimatedCompletionDate, + buildStartDate: submission.buildStartDate, + }; + buildTimingInfo = buildTimingInfo ?? this.startedProcessingCache.get(submission.commitHash!); + this.removeSubmissionFromProcessingCache(submission.commitHash!); + const remainingTime = this.getExpectedRemainingTimeForBuild(submission); + if (remainingTime > 0) { + this.emitBuildingSubmission(participationId, exerciseId, submission, buildTimingInfo); + this.startResultWaitingTimer(participationId, remainingTime); + return { participationId, submission: submissionToBeProcessed, submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION }; + } } // The server sends the latest submission without a result - so it could be that the result is too old. In this case the error is shown directly. this.emitFailedSubmission(participationId, exerciseId); @@ -554,7 +742,7 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi this.emitNoPendingSubmission(participationId, exerciseId); return { participationId, submission: undefined, submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION }; }), - // Now update the exercise build state object and start the result subscription regardless of the submission state. + // Now update the exercise build state object and start the build and result subscription regardless of the submission state. tap((submissionStateObj: ProgrammingSubmissionStateObj) => { const exerciseSubmissionState: ExerciseSubmissionState = { ...(this.exerciseBuildState[exerciseId] || {}), [participationId]: submissionStateObj }; this.exerciseBuildState = { ...this.exerciseBuildState, [exerciseId]: exerciseSubmissionState }; @@ -575,6 +763,16 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi return { ...acc, [participationId]: { participationId, submissionState, submission } }; } + private didSubmissionStartProcessing(commitHash: string): boolean { + return !!this.startedProcessingCache.get(commitHash); + } + + private removeSubmissionFromProcessingCache(commitHash: string): void { + if (this.startedProcessingCache.has(commitHash)) { + this.startedProcessingCache.delete(commitHash); + } + } + /** * Returns programming submissions for exercise from the server * @param exerciseId the id of the exercise @@ -655,9 +853,13 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi this.resultSubscriptions = {}; Object.values(this.resultTimerSubscriptions).forEach((sub) => sub.unsubscribe()); this.resultTimerSubscriptions = {}; + Object.values(this.queueEstimateTimerSubscriptions).forEach((sub) => sub.unsubscribe()); + this.queueEstimateTimerSubscriptions = {}; this.submissionTopicsSubscribed.forEach((topic) => this.websocketService.unsubscribe(topic)); this.submissionTopicsSubscribed.forEach((_, participationId) => this.participationWebsocketService.unsubscribeForLatestResultOfParticipation(participationId, exercise)); this.submissionTopicsSubscribed.clear(); + this.submissionProcessingTopicsSubscribed.forEach((topic) => this.websocketService.unsubscribe(topic)); + this.submissionProcessingTopicsSubscribed.clear(); this.submissionSubjects = {}; this.exerciseBuildStateSubjects.delete(exercise.id!); } @@ -679,5 +881,30 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi this.websocketService.unsubscribe(submissionTopic); } } + const submissionProcessingTopic = this.submissionProcessingTopicsSubscribed.get(participationId); + if (submissionProcessingTopic) { + this.submissionProcessingTopicsSubscribed.delete(participationId); + + const openSubscriptionsForTopic = [...this.submissionProcessingTopicsSubscribed.values()].filter((topic: string) => topic === submissionProcessingTopic).length; + // Only unsubscribe if no other participations are using this topic + if (openSubscriptionsForTopic === 0) { + this.websocketService.unsubscribe(submissionProcessingTopic); + } + } + } + + /** + * Set the local CI profile to determine which build system is used. Used to set the state in tests. + * @param isLocalCIProfile + */ + public setLocalCIProfile(isLocalCIProfile: boolean) { + this.isLocalCIProfile = isLocalCIProfile; + } + + /** + * Get the local CI profile to determine which build system is used. + */ + public getIsLocalCIProfile() { + return this.isLocalCIProfile; } } diff --git a/src/main/webapp/app/exercises/shared/result/result.component.html b/src/main/webapp/app/exercises/shared/result/result.component.html index 01207f3c5052..969466da2e55 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.html +++ b/src/main/webapp/app/exercises/shared/result/result.component.html @@ -4,12 +4,37 @@ --> @switch (templateStatus) { - @case (ResultTemplateStatus.IS_BUILDING) { + @case (ResultTemplateStatus.IS_QUEUED) { - + + @if (estimatedRemaining && estimatedRemaining > 0) { + {{ estimatedRemaining | artemisDurationFromSeconds }} + } } + @case (ResultTemplateStatus.IS_BUILDING) { + @if (showProgressBar && estimatedRemaining) { +
+
+
+
+ + {{ estimatedRemaining | artemisDurationFromSeconds }} +
+
+ +
+ } @else { + + + + @if (estimatedRemaining && estimatedRemaining > 0) { + {{ estimatedRemaining | artemisDurationFromSeconds }} + } + + } + } @case (ResultTemplateStatus.FEEDBACK_GENERATION_FAILED) { @if (result) { @if (showIcon) { diff --git a/src/main/webapp/app/exercises/shared/result/result.component.ts b/src/main/webapp/app/exercises/shared/result/result.component.ts index 87a444e579b2..a6dfcc9afca6 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/result.component.ts @@ -55,6 +55,7 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { @Input() participation: Participation; @Input() isBuilding: boolean; + @Input() isQueued = false; @Input() short = true; @Input() result?: Result; @Input() showUngradedResults = false; @@ -64,6 +65,9 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { @Input() showCompletion = true; @Input() missingResultInfo = MissingResultInformation.NONE; @Input() exercise?: Exercise; + @Input() estimatedCompletionDate?: dayjs.Dayjs; + @Input() buildStartDate?: dayjs.Dayjs; + @Input() showProgressBar = false; textColorClass: string; resultIconClass: IconProp; @@ -73,6 +77,9 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { badge: Badge; resultTooltip?: string; latestDueDate: dayjs.Dayjs | undefined; + estimatedRemaining: number; + progressBarValue: number; + estimatedDurationInterval: ReturnType; // Icons readonly faCircleNotch = faCircleNotch; @@ -163,6 +170,9 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { if (this.resultUpdateSubscription) { clearTimeout(this.resultUpdateSubscription); } + if (this.estimatedDurationInterval) { + clearInterval(this.estimatedDurationInterval); + } } /** @@ -175,9 +185,12 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { this.ngOnInit(); } - if (changes.isBuilding?.currentValue) { + if (changes.isBuilding?.currentValue && changes.isBuilding?.currentValue === true) { // If it's building, we change the templateStatus to building regardless of any other settings. this.templateStatus = ResultTemplateStatus.IS_BUILDING; + } else if (changes.isQueued?.currentValue && changes.isQueued?.currentValue === true) { + // If it's queued, we change the templateStatus to queued regardless of any other settings. + this.templateStatus = ResultTemplateStatus.IS_QUEUED; } else if (changes.missingResultInfo || changes.isBuilding?.previousValue) { // If ... // ... the result was building and is not building anymore, or @@ -186,13 +199,25 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { this.evaluate(); } + + if (changes.estimatedCompletionDate && this.estimatedCompletionDate) { + clearInterval(this.estimatedDurationInterval); + this.estimatedDurationInterval = setInterval(() => { + this.estimatedRemaining = Math.max(0, dayjs(this.estimatedCompletionDate).diff(dayjs(), 'seconds')); + const estimatedDuration = dayjs(this.estimatedCompletionDate).diff(dayjs(this.buildStartDate), 'seconds'); + this.progressBarValue = Math.round((1 - this.estimatedRemaining / estimatedDuration) * 100); + if (this.estimatedRemaining <= 0) { + clearInterval(this.estimatedDurationInterval); + } + }, 1000); // 1 second + } } /** * Sets the corresponding icon, styling and message to display results. */ evaluate() { - this.templateStatus = evaluateTemplateStatus(this.exercise, this.participation, this.result, this.isBuilding, this.missingResultInfo); + this.templateStatus = evaluateTemplateStatus(this.exercise, this.participation, this.result, this.isBuilding, this.missingResultInfo, this.isQueued); if (this.templateStatus === ResultTemplateStatus.LATE) { this.textColorClass = getTextColorClass(this.result, this.templateStatus); this.resultIconClass = getResultIconClass(this.result, this.templateStatus); diff --git a/src/main/webapp/app/exercises/shared/result/result.utils.ts b/src/main/webapp/app/exercises/shared/result/result.utils.ts index ad0abafcd42d..2b5bd7fac1da 100644 --- a/src/main/webapp/app/exercises/shared/result/result.utils.ts +++ b/src/main/webapp/app/exercises/shared/result/result.utils.ts @@ -29,6 +29,11 @@ export enum ResultTemplateStatus { * This is currently only relevant for programming exercises. */ IS_BUILDING = 'IS_BUILDING', + /** + * Submission is currently queued and will be processed soon. + * This is currently only relevant for programming exercises. + */ + IS_QUEUED = 'IS_QUEUED', /** * An automatic feedback suggestion is currently being generated and should be available soon. * This is currently only relevant for programming exercises. @@ -150,6 +155,7 @@ export const evaluateTemplateStatus = ( result: Result | undefined, isBuilding: boolean, missingResultInfo = MissingResultInformation.NONE, + isQueued = false, ): ResultTemplateStatus => { // Fallback if participation is not set if (!participation || !exercise) { @@ -212,7 +218,9 @@ export const evaluateTemplateStatus = ( // Evaluate status for programming and quiz exercises if (isProgrammingOrQuiz(participation)) { - if (isBuilding) { + if (isQueued) { + return ResultTemplateStatus.IS_QUEUED; + } else if (isBuilding) { return ResultTemplateStatus.IS_BUILDING; } else if (isAIResultAndIsBeingProcessed(result)) { return ResultTemplateStatus.IS_GENERATING_FEEDBACK; diff --git a/src/main/webapp/app/exercises/shared/result/updating-result.component.html b/src/main/webapp/app/exercises/shared/result/updating-result.component.html index b6998df616a5..afd60978e897 100644 --- a/src/main/webapp/app/exercises/shared/result/updating-result.component.html +++ b/src/main/webapp/app/exercises/shared/result/updating-result.component.html @@ -4,6 +4,7 @@ [result]="result" [participation]="participation" [isBuilding]="isBuilding" + [isQueued]="isQueued" [short]="short" [showUngradedResults]="showUngradedResults" [showBadge]="showBadge" @@ -11,4 +12,7 @@ [missingResultInfo]="missingResultInfo" [isInSidebarCard]="isInSidebarCard" [showCompletion]="showCompletion" + [estimatedCompletionDate]="estimatedCompletionDate" + [buildStartDate]="buildStartDate" + [showProgressBar]="showProgressBar" /> diff --git a/src/main/webapp/app/exercises/shared/result/updating-result.component.ts b/src/main/webapp/app/exercises/shared/result/updating-result.component.ts index ac2f1cc9ae4d..df1bc59a4ae3 100644 --- a/src/main/webapp/app/exercises/shared/result/updating-result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/updating-result.component.ts @@ -4,7 +4,7 @@ import { filter, map, tap } from 'rxjs/operators'; import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; import { RepositoryService } from 'app/exercises/shared/result/repository.service'; import dayjs from 'dayjs/esm'; -import { ProgrammingSubmissionService, ProgrammingSubmissionState } from 'app/exercises/programming/participate/programming-submission.service'; +import { BuildTimingInfo, ProgrammingSubmissionService, ProgrammingSubmissionState } from 'app/exercises/programming/participate/programming-submission.service'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; import { ResultService } from 'app/exercises/shared/result/result.service'; @@ -35,6 +35,7 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { @Input() showIcon = true; @Input() isInSidebarCard = false; @Input() showCompletion = true; + @Input() showProgressBar = false; @Output() showResult = new EventEmitter(); /** * @property personalParticipation Whether the participation belongs to the user (by being a student) or not (by being an instructor) @@ -45,6 +46,9 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { result?: Result; isBuilding: boolean; + isQueued: boolean; + estimatedCompletionDate?: dayjs.Dayjs; + buildStartDate?: dayjs.Dayjs; missingResultInfo = MissingResultInformation.NONE; public resultSubscription: Subscription; public submissionSubscription: Subscription; @@ -134,7 +138,7 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { .getLatestPendingSubmissionByParticipationId(this.participation.id!, this.exercise.id!, this.personalParticipation) .pipe( filter(({ submission }) => this.shouldUpdateSubmissionState(submission)), - tap(({ submissionState }) => this.updateSubmissionState(submissionState)), + tap(({ submissionState, buildTimingInfo }) => this.updateSubmissionState(submissionState, buildTimingInfo)), ) .subscribe(); } @@ -169,10 +173,16 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { * Updates the shown status based on the given state of a submission. * * @param submissionState the submission is currently in. + * @param buildTimingInfo object container the build start time and the estimated completion time. */ - private updateSubmissionState(submissionState: ProgrammingSubmissionState) { + private updateSubmissionState(submissionState: ProgrammingSubmissionState, buildTimingInfo?: BuildTimingInfo) { + this.isQueued = submissionState === ProgrammingSubmissionState.IS_QUEUED; this.isBuilding = submissionState === ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION; + if (this.submissionService.getIsLocalCIProfile()) { + this.updateBuildTimingInfo(submissionState, buildTimingInfo); + } + if (submissionState === ProgrammingSubmissionState.HAS_FAILED_SUBMISSION) { this.missingResultInfo = this.generateMissingResultInfoForFailedProgrammingExerciseSubmission(); } else { @@ -180,4 +190,28 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { this.missingResultInfo = MissingResultInformation.NONE; } } + + /** + * Updates the build timing information based on the submission state. + * + * @param {ProgrammingSubmissionState} submissionState - The current state of the submission. + * @param {BuildTimingInfo} [buildTimingInfo] - Optional object containing the build start time and the estimated completion time. + */ + private updateBuildTimingInfo(submissionState: ProgrammingSubmissionState, buildTimingInfo?: BuildTimingInfo) { + if (submissionState === ProgrammingSubmissionState.IS_QUEUED) { + this.buildStartDate = undefined; + this.submissionService.fetchQueueReleaseDateEstimationByParticipationId(this.participation.id!).subscribe((releaseDate) => { + if (releaseDate && !this.isBuilding) { + this.estimatedCompletionDate = releaseDate; + } + }); + } else if ( + submissionState === ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION && + buildTimingInfo && + dayjs(buildTimingInfo?.estimatedCompletionDate).isAfter(dayjs()) + ) { + this.estimatedCompletionDate = buildTimingInfo?.estimatedCompletionDate; + this.buildStartDate = buildTimingInfo?.buildStartDate; + } + } } diff --git a/src/main/webapp/app/overview/submission-result-status.module.ts b/src/main/webapp/app/overview/submission-result-status.module.ts index b1e04f09fc70..a32af79b708b 100644 --- a/src/main/webapp/app/overview/submission-result-status.module.ts +++ b/src/main/webapp/app/overview/submission-result-status.module.ts @@ -4,9 +4,10 @@ import { SubmissionResultStatusComponent } from 'app/overview/submission-result- import { UpdatingResultComponent } from 'app/exercises/shared/result/updating-result.component'; import { ArtemisProgrammingExerciseActionsModule } from 'app/exercises/programming/shared/actions/programming-exercise-actions.module'; import { ResultComponent } from 'app/exercises/shared/result/result.component'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; @NgModule({ - imports: [ArtemisSharedModule, ArtemisProgrammingExerciseActionsModule], + imports: [ArtemisSharedModule, ArtemisProgrammingExerciseActionsModule, ArtemisSharedComponentModule], declarations: [SubmissionResultStatusComponent, UpdatingResultComponent, ResultComponent], exports: [SubmissionResultStatusComponent, UpdatingResultComponent, ResultComponent], }) diff --git a/src/main/webapp/i18n/de/editor.json b/src/main/webapp/i18n/de/editor.json index abd74a5c2b5f..b38fb5d5f08c 100644 --- a/src/main/webapp/i18n/de/editor.json +++ b/src/main/webapp/i18n/de/editor.json @@ -31,6 +31,9 @@ "submitDescription": "Staged, committed, pushed, kompiliert und testet Deine Änderungen.", "buildOutput": "Build Ergebnisse", "building": "Build und Tests werden ausgeführt...", + "queued": "In Warteschlange...", + "eta": "ETA:", + "etaInfo": "Geschätzte Zeit bis zum Abschluss des Builds. Dies ist eine Schätzung und kann variieren.", "buildFailed": "Build gescheitert", "noBuildOutput": "Keine Build-Ergebnisse verfügbar.", "generatingFeedback": "Feedback wird generiert...", diff --git a/src/main/webapp/i18n/en/editor.json b/src/main/webapp/i18n/en/editor.json index 18442db3ca14..f2cbfb3ecb8f 100644 --- a/src/main/webapp/i18n/en/editor.json +++ b/src/main/webapp/i18n/en/editor.json @@ -31,6 +31,9 @@ "submitDescription": "Stage, commit, push, build and test your changes.", "buildOutput": "  Build Output", "building": "Building and testing...", + "queued": "Queued...", + "eta": "ETA:", + "etaInfo": "Estimated time until build is finished. This is an estimate and can vary.", "buildFailed": "Build failed", "noBuildOutput": "No build results available", "generatingFeedback": "Generating feedback...", diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java index bbaa18df71b1..0ace95e17d39 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java @@ -4,6 +4,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.LOCALCI_WORKING_DIRECTORY; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.within; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; @@ -21,6 +22,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -59,6 +61,7 @@ import de.tum.cit.aet.artemis.exercise.domain.ExerciseMode; import de.tum.cit.aet.artemis.exercise.domain.Team; import de.tum.cit.aet.artemis.programming.AbstractProgrammingIntegrationLocalCILocalVCTestBase; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingSubmission; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; @@ -92,6 +95,10 @@ protected String getTestPrefix() { private String commitHash; + private IQueue queuedJobs; + + private IMap processingJobs; + @BeforeAll void setupAll() { CredentialsProvider.setDefault(new UsernamePasswordCredentialsProvider(localVCUsername, localVCPassword)); @@ -121,6 +128,9 @@ void initRepositories() throws Exception { Map.of("commitHash", commitHash), Map.of("commitHash", commitHash)); localVCLocalCITestService.mockInspectImage(dockerClient); + + queuedJobs = hazelcastInstance.getQueue("buildJobQueue"); + processingJobs = hazelcastInstance.getMap("processingJobs"); } @AfterEach @@ -503,4 +513,33 @@ void testPauseAndResumeBuildAgent() { hazelcastInstance.getTopic("resumeBuildAgentTopic").publish(buildAgentName); localVCLocalCITestService.testLatestSubmission(studentParticipation.getId(), commitHash, 1, false); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testBuildJobTimingInfo() { + // Pause build agent processing + sharedQueueProcessingService.removeListenerAndCancelScheduledFuture(); + ProgrammingExerciseBuildConfig buildConfig = programmingExercise.getBuildConfig(); + buildConfig.setBuildDurationSeconds(20); + programmingExerciseBuildConfigRepository.save(buildConfig); + + ProgrammingExerciseStudentParticipation studentParticipation = localVCLocalCITestService.createParticipation(programmingExercise, student1Login); + + localVCServletService.processNewPush(commitHash, studentAssignmentRepository.originGit.getRepository()); + + await().until(() -> queuedJobs.stream().anyMatch(buildJobQueueItem -> buildJobQueueItem.buildConfig().commitHashToBuild().equals(commitHash) + && buildJobQueueItem.participationId() == studentParticipation.getId())); + + BuildJobQueueItem item = queuedJobs.stream().filter(i -> i.buildConfig().commitHashToBuild().equals(commitHash) && i.participationId() == studentParticipation.getId()) + .findFirst().orElseThrow(); + assertThat(item.jobTimingInfo().estimatedDuration()).isEqualTo(24); + sharedQueueProcessingService.init(); + + await().until(() -> processingJobs.values().stream().anyMatch(buildJobQueueItem -> buildJobQueueItem.buildConfig().commitHashToBuild().equals(commitHash) + && buildJobQueueItem.participationId() == studentParticipation.getId())); + item = processingJobs.values().stream().filter(i -> i.buildConfig().commitHashToBuild().equals(commitHash) && i.participationId() == studentParticipation.getId()) + .findFirst().orElseThrow(); + assertThat(item.jobTimingInfo().estimatedDuration()).isEqualTo(24); + assertThat(item.jobTimingInfo().estimatedCompletionDate()).isCloseTo(item.jobTimingInfo().buildStartDate().plusSeconds(24), within(500, ChronoUnit.MILLIS)); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java index a4d157d1a690..2a88aaa6aa16 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.programming.icl; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; import static org.awaitility.Awaitility.await; import java.net.URLEncoder; @@ -8,6 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -82,9 +84,9 @@ void createJobs() { // temporarily remove listener to avoid triggering build job processing sharedQueueProcessingService.removeListenerAndCancelScheduledFuture(); - JobTimingInfo jobTimingInfo1 = new JobTimingInfo(ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), ZonedDateTime.now().plusMinutes(3)); - JobTimingInfo jobTimingInfo2 = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2)); - JobTimingInfo jobTimingInfo3 = new JobTimingInfo(ZonedDateTime.now().minusMinutes(10), ZonedDateTime.now().minusMinutes(9), ZonedDateTime.now().plusSeconds(150)); + JobTimingInfo jobTimingInfo1 = new JobTimingInfo(ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), ZonedDateTime.now().plusMinutes(3), null, 20); + JobTimingInfo jobTimingInfo2 = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), null, 20); + JobTimingInfo jobTimingInfo3 = new JobTimingInfo(ZonedDateTime.now().minusMinutes(10), ZonedDateTime.now().minusMinutes(9), ZonedDateTime.now().plusSeconds(150), null, 20); BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null, 0, null, null, null); RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); @@ -93,8 +95,8 @@ void createJobs() { buildAgent = new BuildAgentDTO(buildAgentShortName, memberAddress, buildAgentDisplayName); job1 = new BuildJobQueueItem("1", "job1", buildAgent, 1, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); - job2 = new BuildJobQueueItem("2", "job2", buildAgent, 2, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo2, buildConfig, null); - agent1 = new BuildAgentInformation(buildAgent, 1, 0, new ArrayList<>(List.of(job1)), BuildAgentInformation.BuildAgentStatus.IDLE, new ArrayList<>(List.of(job2)), null); + job2 = new BuildJobQueueItem("2", "job2", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo2, buildConfig, null); + agent1 = new BuildAgentInformation(buildAgent, 2, 1, new ArrayList<>(List.of(job1)), BuildAgentInformation.BuildAgentStatus.IDLE, new ArrayList<>(List.of(job2)), null); BuildJobQueueItem finishedJobQueueItem1 = new BuildJobQueueItem("3", "job3", buildAgent, 3, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); BuildJobQueueItem finishedJobQueueItem2 = new BuildJobQueueItem("4", "job4", buildAgent, 4, course.getId() + 1, 1, 1, 1, BuildStatus.FAILED, repositoryInfo, jobTimingInfo2, @@ -279,7 +281,7 @@ void testGetFinishedBuildJobs_returnsFilteredJobs() throws Exception { // Create a failed job to filter for JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(1).plusMinutes(2), - ZonedDateTime.now().plusDays(1).plusMinutes(10)); + ZonedDateTime.now().plusDays(1).plusMinutes(10), null, 0); BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null, 0, null, null, null); RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); var failedJob1 = new BuildJobQueueItem("5", "job5", buildAgent, 1, course.getId(), 1, 1, 1, BuildStatus.FAILED, repositoryInfo, jobTimingInfo, buildConfig, null); @@ -370,4 +372,38 @@ void testPauseBuildAgent() throws Exception { request.put("/api/admin/agent/" + URLEncoder.encode(agent1.buildAgent().name(), StandardCharsets.UTF_8) + "/resume", null, HttpStatus.NO_CONTENT); await().until(() -> buildAgentInformation.get(agent1.buildAgent().memberAddress()).status() == BuildAgentInformation.BuildAgentStatus.IDLE); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testBuildJob() throws Exception { + var now = ZonedDateTime.now(); + JobTimingInfo jobTimingInfo1 = new JobTimingInfo(now, now, null, now.plusSeconds(24), 24); + JobTimingInfo jobTimingInfo2 = new JobTimingInfo(now, now.plusSeconds(5), null, now.plusSeconds(29), 24); + JobTimingInfo jobTimingInfo3 = new JobTimingInfo(now.plusSeconds(1), null, null, null, 24); + JobTimingInfo jobTimingInfo4 = new JobTimingInfo(now.plusSeconds(2), null, null, null, 24); + JobTimingInfo jobTimingInfo5 = new JobTimingInfo(now.plusSeconds(3), null, null, null, 24); + + BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null, 0, null, null, null); + RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); + + var job1 = new BuildJobQueueItem("1", "job1", buildAgent, 1, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); + var job2 = new BuildJobQueueItem("2", "job2", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo2, buildConfig, null); + var job3 = new BuildJobQueueItem("3", "job3", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo3, buildConfig, null); + var job4 = new BuildJobQueueItem("4", "job4", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo4, buildConfig, null); + var job5 = new BuildJobQueueItem("5", "job5", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo5, buildConfig, null); + + processingJobs.clear(); + processingJobs.put(job1.id(), job1); + processingJobs.put(job2.id(), job2); + queuedJobs.clear(); + queuedJobs.put(job3); + queuedJobs.put(job4); + queuedJobs.put(job5); + + agent1 = new BuildAgentInformation(buildAgent, 2, 2, new ArrayList<>(List.of(job1, job2)), BuildAgentInformation.BuildAgentStatus.ACTIVE, null, null); + buildAgentInformation.put(buildAgent.memberAddress(), agent1); + + var queueDurationEstimation = sharedQueueManagementService.getBuildJobEstimatedQueueDuration(job4.participationId()); + assertThat(queueDurationEstimation).isCloseTo(now.plusSeconds(48), within(1, ChronoUnit.SECONDS)); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java index fe88b498bb47..96b77b2b2536 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java @@ -70,7 +70,7 @@ void testReturnCorrectBuildStatus() { ProgrammingExercise exercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); ProgrammingExerciseStudentParticipation participation = participationUtilService.addStudentParticipationForProgrammingExercise(exercise, TEST_PREFIX + "student1"); - JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2)); + JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), null, 0); BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null, 0, null, null, null); RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); diff --git a/src/test/javascript/spec/component/exercises/shared/result.spec.ts b/src/test/javascript/spec/component/exercises/shared/result.spec.ts index ff076e80d8c8..7b93469e2844 100644 --- a/src/test/javascript/spec/component/exercises/shared/result.spec.ts +++ b/src/test/javascript/spec/component/exercises/shared/result.spec.ts @@ -223,4 +223,12 @@ describe('ResultComponent', () => { expect(warningIcon).toBeNull(); } }); + + it('should trigger Interval creation on estimatedCompletionDate change', () => { + component.estimatedCompletionDate = dayjs().add(20, 'seconds'); + component.ngOnChanges({ + estimatedCompletionDate: { previousValue: undefined, currentValue: component.estimatedCompletionDate, firstChange: true, isFirstChange: () => true }, + }); + expect(component.estimatedDurationInterval).toBeDefined(); + }); }); diff --git a/src/test/javascript/spec/component/shared/updating-result.component.spec.ts b/src/test/javascript/spec/component/shared/updating-result.component.spec.ts index 9b8c8b9435e9..42294380996d 100644 --- a/src/test/javascript/spec/component/shared/updating-result.component.spec.ts +++ b/src/test/javascript/spec/component/shared/updating-result.component.spec.ts @@ -4,7 +4,12 @@ import { DebugElement } from '@angular/core'; import { BehaviorSubject, of } from 'rxjs'; import { ArtemisTestModule } from '../../test.module'; import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; -import { ProgrammingSubmissionService, ProgrammingSubmissionState } from 'app/exercises/programming/participate/programming-submission.service'; +import { + BuildTimingInfo, + ProgrammingSubmissionService, + ProgrammingSubmissionState, + ProgrammingSubmissionStateObj, +} from 'app/exercises/programming/participate/programming-submission.service'; import { MockProgrammingSubmissionService } from '../../helpers/mocks/service/mock-programming-submission.service'; import { triggerChanges } from '../../helpers/utils/general.utils'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; @@ -28,6 +33,8 @@ describe('UpdatingResultComponent', () => { let subscribeForLatestResultOfParticipationSubject: BehaviorSubject; let getLatestPendingSubmissionStub: jest.SpyInstance; + let getIsLocalCIProfileStub: jest.SpyInstance; + let fetchQueueReleaseDateEstimationByParticipationIdStub: jest.SpyInstance; const exercise = { id: 20 } as Exercise; const student = { id: 99 }; @@ -41,6 +48,10 @@ describe('UpdatingResultComponent', () => { const newUngradedResult = { id: 15, rated: false } as Result; const submission = { id: 1 } as Submission; + const buildTimingInfo: BuildTimingInfo = { + buildStartDate: dayjs().subtract(10, 'second'), + estimatedCompletionDate: dayjs().add(10, 'second'), + }; beforeEach(() => { TestBed.configureTestingModule({ @@ -69,6 +80,10 @@ describe('UpdatingResultComponent', () => { getLatestPendingSubmissionStub = jest .spyOn(programmingSubmissionService, 'getLatestPendingSubmissionByParticipationId') .mockReturnValue(of(programmingSubmissionStateObj)); + getIsLocalCIProfileStub = jest.spyOn(programmingSubmissionService, 'getIsLocalCIProfile').mockReturnValue(false); + fetchQueueReleaseDateEstimationByParticipationIdStub = jest + .spyOn(programmingSubmissionService, 'fetchQueueReleaseDateEstimationByParticipationId') + .mockReturnValue(of(undefined)); }); }); @@ -153,12 +168,17 @@ describe('UpdatingResultComponent', () => { it('should set the isBuilding attribute to true if exerciseType is PROGRAMMING and there is a latest pending submission', () => { comp.exercise = { id: 99, type: ExerciseType.PROGRAMMING } as Exercise; - getLatestPendingSubmissionStub.mockReturnValue(of({ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission, participationId: 3 })); + getLatestPendingSubmissionStub.mockReturnValue( + of({ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission, participationId: 3, buildTimingInfo }), + ); cleanInitializeGraded(); expect(getLatestPendingSubmissionStub).toHaveBeenCalledOnce(); expect(getLatestPendingSubmissionStub).toHaveBeenCalledWith(comp.participation.id, comp.exercise.id, true); expect(comp.isBuilding).toBeTrue(); expect(comp.missingResultInfo).toBe(MissingResultInformation.NONE); + // LocalCI is not enabled, so the buildStartDate and estimatedCompletionDate should not be set + expect(comp.buildStartDate).toBeUndefined(); + expect(comp.estimatedCompletionDate).toBeUndefined(); }); it('should set the isBuilding attribute to false if exerciseType is PROGRAMMING and there is no pending submission anymore', () => { @@ -217,4 +237,32 @@ describe('UpdatingResultComponent', () => { expect(comp.isBuilding).toBeUndefined(); expect(comp.missingResultInfo).toBe(MissingResultInformation.NONE); }); + + it('should set the isQueue and isBuilding attribute to true with correct timing', () => { + getIsLocalCIProfileStub.mockReturnValue(true); + comp.exercise = { id: 99, type: ExerciseType.PROGRAMMING } as Exercise; + const pendingSubmissionSubject = new BehaviorSubject({ + submissionState: ProgrammingSubmissionState.IS_QUEUED, + submission, + participationId: 3, + } as ProgrammingSubmissionStateObj); + getLatestPendingSubmissionStub.mockReturnValue(pendingSubmissionSubject); + const queueReleaseDate = dayjs().add(3, 'second'); + fetchQueueReleaseDateEstimationByParticipationIdStub.mockReturnValue(of(queueReleaseDate)); + cleanInitializeGraded(); + expect(getLatestPendingSubmissionStub).toHaveBeenCalledOnce(); + expect(getLatestPendingSubmissionStub).toHaveBeenCalledWith(comp.participation.id, comp.exercise.id, true); + + expect(comp.isBuilding).toBeFalsy(); + expect(comp.isQueued).toBeTruthy(); + expect(comp.missingResultInfo).toBe(MissingResultInformation.NONE); + expect(comp.estimatedCompletionDate).toBe(queueReleaseDate); + + // Now the submission is building + pendingSubmissionSubject.next({ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission, participationId: 3, buildTimingInfo }); + expect(comp.isBuilding).toBeTruthy(); + expect(comp.isQueued).toBeFalsy(); + expect(comp.buildStartDate).toBe(buildTimingInfo.buildStartDate); + expect(comp.estimatedCompletionDate).toBe(buildTimingInfo.estimatedCompletionDate); + }); }); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-programming-submission.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-programming-submission.service.ts index 582c51dd4ba1..c6d43b74971d 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-programming-submission.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-programming-submission.service.ts @@ -1,5 +1,6 @@ import { IProgrammingSubmissionService, ProgrammingSubmissionState, ProgrammingSubmissionStateObj } from 'app/exercises/programming/participate/programming-submission.service'; import { EMPTY, Observable, of } from 'rxjs'; +import dayjs from 'dayjs'; import { Exercise } from 'app/entities/exercise.model'; export class MockProgrammingSubmissionService implements IProgrammingSubmissionService { @@ -14,4 +15,6 @@ export class MockProgrammingSubmissionService implements IProgrammingSubmissionS triggerInstructorBuildForAllParticipationsOfExercise: (exerciseId: number) => Observable; triggerInstructorBuildForParticipationsOfExercise: (exerciseId: number, participationIds: number[]) => Observable; downloadSubmissionInOrion: (exerciseId: number, submissionId: number, correctionRound: number) => void; + getIsLocalCIProfile = () => false; + fetchQueueReleaseDateEstimationByParticipationId: (participationId: number) => Observable = () => of(undefined); } diff --git a/src/test/javascript/spec/service/programming-submission.service.spec.ts b/src/test/javascript/spec/service/programming-submission.service.spec.ts index eaed7a0be320..3963251f704b 100644 --- a/src/test/javascript/spec/service/programming-submission.service.spec.ts +++ b/src/test/javascript/spec/service/programming-submission.service.spec.ts @@ -3,6 +3,7 @@ import { BehaviorSubject, Subject, lastValueFrom, of } from 'rxjs'; import { range as _range } from 'lodash-es'; import { MockWebsocketService } from '../helpers/mocks/service/mock-websocket.service'; import { + BuildTimingInfo, ExerciseSubmissionState, ProgrammingSubmissionService, ProgrammingSubmissionState, @@ -20,6 +21,9 @@ import { HttpClient, provideHttpClient } from '@angular/common/http'; import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ProfileService } from '../../../../main/webapp/app/shared/layouts/profiles/profile.service'; +import { MockProfileService } from '../helpers/mocks/service/mock-profile.service'; +import { SubmissionProcessingDTO } from '../../../../main/webapp/app/entities/programming/submission-processing-dto'; describe('ProgrammingSubmissionService', () => { let websocketService: JhiWebsocketService; @@ -38,20 +42,44 @@ describe('ProgrammingSubmissionService', () => { let notifyAllResultSubscribersStub: jest.SpyInstance; let wsSubmissionSubject: Subject; + let wsSubmissionProcessingSubject: Subject; let wsLatestResultSubject: Subject; const participationId = 1; + const exerciseId = 10; const submissionTopic = `/user/topic/newSubmissions`; + const submissionProcessingTopic = `/user/topic/submissionProcessing`; let currentSubmission: Submission; let currentSubmission2: Submission; + let currentProgrammingSubmission: ProgrammingSubmission; + let currentProgrammingSubmissionOld: ProgrammingSubmission; let result: Result; let result2: Result; + let buildTimingInfoEmpty: BuildTimingInfo; + let buildTimingInfo: BuildTimingInfo; + let mockSubmissionProcessingDTO: SubmissionProcessingDTO; + let mockSubmissionProcessingDTOOld: SubmissionProcessingDTO; beforeEach(() => { currentSubmission = { id: 11, submissionDate: dayjs().subtract(20, 'seconds'), participation: { id: participationId } } as any; currentSubmission2 = { id: 12, submissionDate: dayjs().subtract(20, 'seconds'), participation: { id: participationId } } as any; result = { id: 31, submission: currentSubmission } as any; result2 = { id: 32, submission: currentSubmission2 } as any; + buildTimingInfoEmpty = { buildStartDate: undefined, estimatedCompletionDate: undefined }; + buildTimingInfo = { buildStartDate: dayjs().subtract(10, 'seconds'), estimatedCompletionDate: dayjs().add(10, 'seconds') }; + mockSubmissionProcessingDTO = { + exerciseId: exerciseId, + participationId: participationId, + commitHash: 'abc123', + estimatedCompletionDate: buildTimingInfo.estimatedCompletionDate, + buildStartDate: buildTimingInfo.buildStartDate, + }; + mockSubmissionProcessingDTOOld = { + ...mockSubmissionProcessingDTO, + commitHash: 'abc123Old', + }; + currentProgrammingSubmission = { id: 12, submissionDate: dayjs().subtract(20, 'seconds'), participation: { id: participationId }, commitHash: 'abc123' } as any; + currentProgrammingSubmissionOld = { id: 11, submissionDate: dayjs().subtract(40, 'seconds'), participation: { id: participationId }, commitHash: 'abc123Old' } as any; TestBed.configureTestingModule({ imports: [], @@ -61,6 +89,7 @@ describe('ProgrammingSubmissionService', () => { { provide: JhiWebsocketService, useClass: MockWebsocketService }, { provide: ParticipationWebsocketService, useClass: MockParticipationWebsocketService }, { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, + { provide: ProfileService, useClass: MockProfileService }, ], }) .compileComponents() @@ -76,7 +105,15 @@ describe('ProgrammingSubmissionService', () => { wsSubscribeStub = jest.spyOn(websocketService, 'subscribe'); wsUnsubscribeStub = jest.spyOn(websocketService, 'unsubscribe'); wsSubmissionSubject = new Subject(); - wsReceiveStub = jest.spyOn(websocketService, 'receive').mockReturnValue(wsSubmissionSubject); + wsSubmissionProcessingSubject = new Subject(); + wsReceiveStub = jest.spyOn(websocketService, 'receive').mockImplementation((topic: string) => { + if (topic === submissionTopic) { + return wsSubmissionSubject; + } else if (topic === submissionProcessingTopic) { + return wsSubmissionProcessingSubject; + } + return new Subject(); + }); wsLatestResultSubject = new Subject(); participationWsLatestResultStub = jest .spyOn(participationWebsocketService, 'subscribeForLatestResultOfParticipation') @@ -110,8 +147,14 @@ describe('ProgrammingSubmissionService', () => { it('should query httpService endpoint and setup the websocket subscriptions if no subject is cached for the provided participation', () => { httpGetStub.mockReturnValue(of(currentSubmission)); let submission; + submissionService.setLocalCIProfile(false); submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((sub) => (submission = sub)); - expect(submission).toEqual({ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }); + expect(submission).toEqual({ + submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, + submission: currentSubmission, + participationId, + buildTimingInfo: buildTimingInfoEmpty, + }); expect(wsSubscribeStub).toHaveBeenCalledOnce(); expect(wsSubscribeStub).toHaveBeenCalledWith(submissionTopic); expect(wsReceiveStub).toHaveBeenCalledOnce(); @@ -120,16 +163,39 @@ describe('ProgrammingSubmissionService', () => { expect(participationWsLatestResultStub).toHaveBeenCalledWith(participationId, true, 10); }); + it('should query httpService endpoint and setup the websocket subscriptions if no subject is cached for the provided participation with localCI profile', () => { + httpGetStub.mockReturnValue(of(currentSubmission)); + let submission; + submissionService.setLocalCIProfile(true); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((sub) => (submission = sub)); + expect(submission).toEqual({ + submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, + submission: currentSubmission, + participationId, + buildTimingInfo: buildTimingInfoEmpty, + }); + expect(wsSubscribeStub).toHaveBeenCalledTimes(2); + expect(wsSubscribeStub).toHaveBeenNthCalledWith(1, submissionTopic); + expect(wsSubscribeStub).toHaveBeenNthCalledWith(2, submissionProcessingTopic); + expect(wsReceiveStub).toHaveBeenCalledTimes(2); + expect(wsReceiveStub).toHaveBeenNthCalledWith(1, submissionTopic); + expect(wsReceiveStub).toHaveBeenNthCalledWith(2, submissionProcessingTopic); + expect(participationWsLatestResultStub).toHaveBeenCalledOnce(); + expect(participationWsLatestResultStub).toHaveBeenCalledWith(participationId, true, 10); + }); + it('should emit undefined when a new result comes in for the given participation to signal that the building process is over', () => { const returnedSubmissions: Array = []; httpGetStub.mockReturnValue(of(currentSubmission)); submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); - expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }]); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId, buildTimingInfo: buildTimingInfoEmpty }, + ]); // Result comes in for submission. result.submission = currentSubmission; wsLatestResultSubject.next(result); expect(returnedSubmissions).toEqual([ - { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId, buildTimingInfo: buildTimingInfoEmpty }, { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, ]); }); @@ -138,11 +204,15 @@ describe('ProgrammingSubmissionService', () => { const returnedSubmissions: Array = []; httpGetStub.mockReturnValue(of(currentSubmission)); submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); - expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }]); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId, buildTimingInfo: buildTimingInfoEmpty }, + ]); // Result comes in for submission. result.submission = currentSubmission2; wsLatestResultSubject.next(result); - expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }]); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId, buildTimingInfo: buildTimingInfoEmpty }, + ]); }); it('should emit the newest submission when it was received through the websocket connection', () => { @@ -371,4 +441,113 @@ describe('ProgrammingSubmissionService', () => { submissionService.unsubscribeForLatestSubmissionOfParticipation(2); expect(wsUnsubscribeStub).toHaveBeenCalledOnce(); }); + + it('should only unsubscribe if no other participations use the topic with localci', () => { + submissionService.setLocalCIProfile(true); + httpGetStub.mockReturnValue(of(currentSubmission)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true); + submissionService.getLatestPendingSubmissionByParticipationId(2, 10, true); + + // Should not unsubscribe as participation 2 still uses the same topic + submissionService.unsubscribeForLatestSubmissionOfParticipation(participationId); + expect(wsUnsubscribeStub).not.toHaveBeenCalled(); + + // Should now unsubscribe as last participation for topic was unsubscribed + submissionService.unsubscribeForLatestSubmissionOfParticipation(2); + expect(wsUnsubscribeStub).toHaveBeenCalledTimes(2); + }); + + it('should emit the newest submission when it was received through the websocket connection with localci', () => { + submissionService.setLocalCIProfile(true); + const returnedSubmissions: Array = []; + // No latest pending submission found. + httpGetStub.mockReturnValue(of(undefined)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // New submission comes in. + wsSubmissionSubject.next(currentProgrammingSubmission); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + ]); + // Submission is now building. + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTO); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId, buildTimingInfo }, + ]); + // Result comes in for submission. + result.submission = currentProgrammingSubmission; + wsLatestResultSubject.next(result); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId, buildTimingInfo }, + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + ]); + }); + + it('should handle when submission processing event before submission event', () => { + submissionService.setLocalCIProfile(true); + const returnedSubmissions: Array = []; + // No latest pending submission found. + httpGetStub.mockReturnValue(of(undefined)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // Submission is now building. + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTO); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // New submission comes in. + wsSubmissionSubject.next(currentProgrammingSubmission); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId, buildTimingInfo }, + ]); + // Result comes in for submission. + result.submission = currentProgrammingSubmission; + wsLatestResultSubject.next(result); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId, buildTimingInfo }, + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + ]); + }); + + it('should not update to building if old submission', () => { + submissionService.setLocalCIProfile(true); + const returnedSubmissions: Array = []; + // No latest pending submission found. + httpGetStub.mockReturnValue(of(undefined)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // New submission comes in. + wsSubmissionSubject.next(currentProgrammingSubmissionOld); + wsSubmissionSubject.next(currentProgrammingSubmission); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmissionOld, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + ]); + // old submission is now building. + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTOOld); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmissionOld, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + ]); + // new submission is now building. + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTO); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmissionOld, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + { + submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, + submission: currentProgrammingSubmission, + participationId, + buildTimingInfo: buildTimingInfo, + }, + ]); + }); });