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 331cb3dd6f77..27bfcdba0e84 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) { @@ -51,6 +54,8 @@ public BuildJobQueueItem(BuildJobQueueItem queueItem, ResultDTO submissionResult public BuildJobQueueItem(BuildJobQueueItem queueItem, BuildAgentDTO buildAgent, int newRetryCount) { this(queueItem.id(), queueItem.name(), buildAgent, queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), newRetryCount, queueItem.priority(), null, - queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null), queueItem.buildConfig(), null); + queueItem.repositoryInfo(), + new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null, null, queueItem.jobTimingInfo().estimatedDuration()), + queueItem.buildConfig(), null); } } 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 6bf67b2626e2..fb58d1a5d6c6 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 @@ -305,7 +305,10 @@ private BuildJobQueueItem addToProcessingJobs() { if (buildJob != null) { String hazelcastMemberAddress = hazelcastInstance.getCluster().getLocalMember().getAddress().toString(); - BuildJobQueueItem processingJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO(buildAgentShortName, hazelcastMemberAddress, buildAgentDisplayName)); + long 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(); @@ -403,7 +406,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 357a0d5a02e1..274b9e393ecb 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 @@ -66,6 +66,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/ProgrammingExerciseBuildStatistics.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildStatistics.java new file mode 100644 index 000000000000..56ab517a7761 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildStatistics.java @@ -0,0 +1,57 @@ +package de.tum.cit.aet.artemis.programming.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; + +@Entity +@Table(name = "programming_exercise_build_statistics") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class ProgrammingExerciseBuildStatistics extends DomainObject { + + @Column(name = "build_duration_seconds") + private long buildDurationSeconds = 0; + + @Column(name = "build_count_when_updated") + private long buildCountWhenUpdated = 0; + + @Column(name = "exercise_id") + private Long exerciseId; + + public ProgrammingExerciseBuildStatistics() { + } + + public ProgrammingExerciseBuildStatistics(Long exerciseId, long buildDurationSeconds, long buildCountWhenUpdated) { + this.buildDurationSeconds = buildDurationSeconds; + this.buildCountWhenUpdated = buildCountWhenUpdated; + this.exerciseId = exerciseId; + } + + public long getBuildDurationSeconds() { + return buildDurationSeconds; + } + + public void setBuildDurationSeconds(long buildDurationSeconds) { + this.buildDurationSeconds = buildDurationSeconds; + } + + public long getBuildCountWhenUpdated() { + return buildCountWhenUpdated; + } + + public void setBuildCountWhenUpdated(long buildCountWhenUpdated) { + this.buildCountWhenUpdated = buildCountWhenUpdated; + } + + public Long getExerciseId() { + return exerciseId; + } + + public void setExerciseId(Long exerciseId) { + this.exerciseId = exerciseId; + } +} 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..7cae77cae3b3 --- /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 submissionDate, 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/repository/BuildJobRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java index d7a5e662c744..834a7dfa621e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java @@ -104,4 +104,20 @@ SELECT COUNT(b) WHERE e.id IN :exerciseIds """) long countBuildJobsByExerciseIds(@Param("exerciseIds") List exerciseIds); + + @Query(""" + SELECT b + FROM BuildJob b + WHERE b.exerciseId = :exerciseId AND b.buildStatus = 'SUCCESSFUL' + ORDER BY b.buildStartDate DESC + LIMIT :limit + """) + List fetchSuccessfulBuildJobsByExerciseIdWithLimit(@Param("exerciseId") Long exerciseId, @Param("limit") int limit); + + @Query(""" + SELECT COUNT(b) + FROM BuildJob b + WHERE b.exerciseId = :exerciseId AND b.buildStatus = 'SUCCESSFUL' + """) + long fetchSuccessfulBuildJobCountByExerciseId(@Param("exerciseId") Long exerciseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseBuildStatisticsRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseBuildStatisticsRepository.java new file mode 100644 index 000000000000..cbb58583cbb2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseBuildStatisticsRepository.java @@ -0,0 +1,18 @@ +package de.tum.cit.aet.artemis.programming.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildStatistics; + +@Profile(PROFILE_CORE) +@Repository +public interface ProgrammingExerciseBuildStatisticsRepository extends ArtemisJpaRepository { + + Optional findByExerciseId(Long exerciseId); +} 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 8c73f1b25850..349aa53bc89e 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; @@ -24,6 +26,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.iris.service.pyris.PyrisEventService; import de.tum.cit.aet.artemis.iris.service.pyris.event.NewResultEvent; @@ -33,6 +36,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) @@ -53,14 +57,17 @@ public class ProgrammingMessagingService { private final Optional pyrisEventService; + private final ParticipationRepository participationRepository; + public ProgrammingMessagingService(GroupNotificationService groupNotificationService, WebsocketMessagingService websocketMessagingService, ResultWebsocketService resultWebsocketService, Optional ltiNewResultService, TeamRepository teamRepository, - Optional pyrisEventService) { + Optional pyrisEventService, ParticipationRepository participationRepository) { this.groupNotificationService = groupNotificationService; this.websocketMessagingService = websocketMessagingService; this.resultWebsocketService = resultWebsocketService; this.ltiNewResultService = ltiNewResultService; this.teamRepository = teamRepository; + this.participationRepository = participationRepository; this.pyrisEventService = pyrisEventService; } @@ -68,6 +75,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"; } @@ -95,7 +106,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 @@ -204,4 +215,30 @@ private void notifyIrisAboutSubmissionStatus(Result result, ProgrammingExerciseS }); } } + + /** + * 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) { + // Eagerly 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)) { + String 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 0e081a93728b..872057e95145 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.ArrayList; import java.util.List; @@ -24,6 +25,8 @@ import de.tum.cit.aet.artemis.buildagent.dto.BuildConfig; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; import de.tum.cit.aet.artemis.buildagent.dto.RepositoryInfo; +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. @@ -39,6 +42,8 @@ public class LocalCIQueueWebsocketService { private final LocalCIWebsocketMessagingService localCIWebsocketMessagingService; + private final ProgrammingMessagingService programmingMessagingService; + private final SharedQueueManagementService sharedQueueManagementService; private final HazelcastInstance hazelcastInstance; @@ -51,10 +56,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; } /** @@ -118,6 +124,9 @@ 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().submissionDate(), event.getValue().jobTimingInfo().buildStartDate(), + event.getValue().jobTimingInfo().estimatedCompletionDate()); } @Override @@ -201,4 +210,10 @@ private static List removeUnnecessaryInformationFromBuild } return filteredBuildAgentSummary; } + + private void notifyUserAboutBuildProcessing(long exerciseId, long participationId, String commitHash, ZonedDateTime submissionDate, ZonedDateTime buildStartDate, + ZonedDateTime estimatedCompletionDate) { + var submissionProcessingDTO = new SubmissionProcessingDTO(exerciseId, participationId, commitHash, submissionDate, buildStartDate, estimatedCompletionDate); + programmingMessagingService.notifyUserAboutSubmissionProcessing(submissionProcessingDTO, exerciseId, participationId); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java index 417ee5bdc630..a453c7accf5b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java @@ -2,10 +2,13 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALCI; +import java.time.Duration; import java.util.List; import java.util.Optional; +import java.util.OptionalDouble; import java.util.UUID; import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; import jakarta.annotation.PreDestroy; @@ -35,6 +38,8 @@ import de.tum.cit.aet.artemis.exercise.domain.SubmissionType; import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildStatistics; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; import de.tum.cit.aet.artemis.programming.domain.build.BuildJob; @@ -42,6 +47,7 @@ import de.tum.cit.aet.artemis.programming.dto.ResultDTO; import de.tum.cit.aet.artemis.programming.exception.BuildTriggerWebsocketError; import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildStatisticsRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.service.BuildLogEntryService; import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseGradingService; @@ -52,6 +58,10 @@ @Service public class LocalCIResultProcessingService { + private static final int BUILD_STATISTICS_UPDATE_THRESHOLD = 10; + + private static final int BUILD_JOB_DURATION_UPDATE_LIMIT = 100; + private static final Logger log = LoggerFactory.getLogger(LocalCIResultProcessingService.class); private final HazelcastInstance hazelcastInstance; @@ -64,6 +74,8 @@ public class LocalCIResultProcessingService { private final ProgrammingExerciseRepository programmingExerciseRepository; + private final ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository; + private final ParticipationRepository participationRepository; private final ProgrammingTriggerService programmingTriggerService; @@ -78,7 +90,8 @@ public class LocalCIResultProcessingService { public LocalCIResultProcessingService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ProgrammingExerciseGradingService programmingExerciseGradingService, ProgrammingMessagingService programmingMessagingService, BuildJobRepository buildJobRepository, ProgrammingExerciseRepository programmingExerciseRepository, - ParticipationRepository participationRepository, ProgrammingTriggerService programmingTriggerService, BuildLogEntryService buildLogEntryService) { + ParticipationRepository participationRepository, ProgrammingTriggerService programmingTriggerService, BuildLogEntryService buildLogEntryService, + ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository) { this.hazelcastInstance = hazelcastInstance; this.programmingExerciseRepository = programmingExerciseRepository; this.participationRepository = participationRepository; @@ -87,6 +100,7 @@ public LocalCIResultProcessingService(@Qualifier("hazelcastInstance") HazelcastI this.buildJobRepository = buildJobRepository; this.programmingTriggerService = programmingTriggerService; this.buildLogEntryService = buildLogEntryService; + this.programmingExerciseBuildStatisticsRepository = programmingExerciseBuildStatisticsRepository; } /** @@ -159,6 +173,13 @@ public void processResult() { } } finally { + ProgrammingExerciseParticipation programmingExerciseParticipation = (ProgrammingExerciseParticipation) participationOptional.orElse(null); + if (programmingExerciseParticipation != null && programmingExerciseParticipation.getExercise() == null) { + ProgrammingExercise exercise = programmingExerciseRepository.getProgrammingExerciseWithBuildConfigFromParticipation(programmingExerciseParticipation); + programmingExerciseParticipation.setExercise(exercise); + programmingExerciseParticipation.setProgrammingExercise(exercise); + } + // save build job to database if (ex != null) { if (ex.getCause() instanceof CancellationException && ex.getMessage().equals("Build job with id " + buildJob.id() + " was cancelled.")) { @@ -171,26 +192,24 @@ public void processResult() { } else { savedBuildJob = saveFinishedBuildJob(buildJob, BuildStatus.SUCCESSFUL, result); - } - - if (participationOptional.isPresent()) { - ProgrammingExerciseParticipation participation = (ProgrammingExerciseParticipation) participationOptional.get(); - if (participation.getExercise() == null) { - participation.setExercise(programmingExerciseRepository.getProgrammingExerciseFromParticipation(participation)); + if (programmingExerciseParticipation != null) { + updateExerciseBuildDurationAsync(programmingExerciseParticipation.getProgrammingExercise()); } + } + if (programmingExerciseParticipation != null) { if (result != null) { - programmingMessagingService.notifyUserAboutNewResult(result, participation); + programmingMessagingService.notifyUserAboutNewResult(result, programmingExerciseParticipation); addResultToBuildAgentsRecentBuildJobs(buildJob, result); } else { - programmingMessagingService.notifyUserAboutSubmissionError((Participation) participation, - new BuildTriggerWebsocketError("Result could not be processed", participation.getId())); + programmingMessagingService.notifyUserAboutSubmissionError((Participation) programmingExerciseParticipation, + new BuildTriggerWebsocketError("Result could not be processed", programmingExerciseParticipation.getId())); } if (!buildLogs.isEmpty()) { if (savedBuildJob != null) { - buildLogEntryService.saveBuildLogsToFile(buildLogs, savedBuildJob.getBuildJobId(), participation.getProgrammingExercise()); + buildLogEntryService.saveBuildLogsToFile(buildLogs, savedBuildJob.getBuildJobId(), programmingExerciseParticipation.getProgrammingExercise()); } else { log.warn("Couldn't save build logs as build job {} was not saved", buildJob.id()); @@ -253,7 +272,7 @@ private void addResultToBuildAgentsRecentBuildJobs(BuildJobQueueItem buildJob, R * * @return the saved the build job */ - public BuildJob saveFinishedBuildJob(BuildJobQueueItem queueItem, BuildStatus buildStatus, Result result) { + private BuildJob saveFinishedBuildJob(BuildJobQueueItem queueItem, BuildStatus buildStatus, Result result) { try { BuildJob buildJob = new BuildJob(queueItem, buildStatus, result); return buildJobRepository.save(buildJob); @@ -264,6 +283,42 @@ public BuildJob saveFinishedBuildJob(BuildJobQueueItem queueItem, BuildStatus bu } } + private void updateExerciseBuildDurationAsync(ProgrammingExercise exercise) { + CompletableFuture.runAsync(() -> updateExerciseBuildDuration(exercise)); + } + + private void updateExerciseBuildDuration(ProgrammingExercise exercise) { + try { + ProgrammingExerciseBuildStatistics buildStatistics = programmingExerciseBuildStatisticsRepository.findByExerciseId(exercise.getId()).orElse(null); + long successfulBuildJobCountByExerciseId = buildJobRepository.fetchSuccessfulBuildJobCountByExerciseId(exercise.getId()); + + boolean hasSuccessfulBuildJobs = successfulBuildJobCountByExerciseId > 0; + boolean exceedsUpdateThreshold = buildStatistics == null + || successfulBuildJobCountByExerciseId - buildStatistics.getBuildCountWhenUpdated() >= BUILD_STATISTICS_UPDATE_THRESHOLD; + + boolean shouldUpdate = hasSuccessfulBuildJobs && exceedsUpdateThreshold; + if (!shouldUpdate) { + return; + } + + OptionalDouble averageBuildDuration = buildJobRepository.fetchSuccessfulBuildJobsByExerciseIdWithLimit(exercise.getId(), BUILD_JOB_DURATION_UPDATE_LIMIT).stream() + .mapToLong(buildJob -> Duration.between(buildJob.getBuildStartDate(), buildJob.getBuildCompletionDate()).toSeconds()).average(); + if (averageBuildDuration.isPresent()) { + if (buildStatistics == null) { + buildStatistics = new ProgrammingExerciseBuildStatistics(exercise.getId(), Math.round(averageBuildDuration.getAsDouble()), successfulBuildJobCountByExerciseId); + } + else { + buildStatistics.setBuildDurationSeconds((long) averageBuildDuration.getAsDouble()); + buildStatistics.setBuildCountWhenUpdated(successfulBuildJobCountByExerciseId); + } + programmingExerciseBuildStatisticsRepository.save(buildStatistics); + } + } + catch (Exception e) { + log.error("Could not update exercise build duration", e); + } + } + public class ResultQueueListener implements ItemListener { @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 0870d1a1d04d..683830123870 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 @@ -37,12 +37,14 @@ import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildStatistics; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; import de.tum.cit.aet.artemis.programming.domain.ProjectType; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildStatisticsRepository; import de.tum.cit.aet.artemis.programming.repository.SolutionProgrammingExerciseParticipationRepository; import de.tum.cit.aet.artemis.programming.service.BuildScriptProviderService; import de.tum.cit.aet.artemis.programming.service.GitService; @@ -97,6 +99,8 @@ public class LocalCITriggerService implements ContinuousIntegrationTriggerServic private final ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository; + private final ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository; + private IQueue queue; private IMap dockerImageCleanupInfo; @@ -105,13 +109,19 @@ public class LocalCITriggerService implements ContinuousIntegrationTriggerServic private final ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService; + private static final int DEFAULT_BUILD_DURATION = 17; + + // Arbitrary value to ensure that the build duration is always a bit higher than the actual build duration + 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, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, LocalCIBuildConfigurationService localCIBuildConfigurationService, GitService gitService, ExerciseDateService exerciseDateService, ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, BuildScriptProviderService buildScriptProviderService, - ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService) { + ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService, + ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository) { this.hazelcastInstance = hazelcastInstance; this.aeolusTemplateService = aeolusTemplateService; this.programmingLanguageConfiguration = programmingLanguageConfiguration; @@ -125,6 +135,7 @@ public LocalCITriggerService(@Qualifier("hazelcastInstance") HazelcastInstance h this.exerciseDateService = exerciseDateService; this.buildScriptProviderService = buildScriptProviderService; this.programmingExerciseBuildConfigService = programmingExerciseBuildConfigService; + this.programmingExerciseBuildStatisticsRepository = programmingExerciseBuildStatisticsRepository; } @PostConstruct @@ -195,10 +206,15 @@ 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); + var buildStatics = loadBuildStatistics(programmingExercise); + + long estimatedDuration = (buildStatics != null && buildStatics.getBuildDurationSeconds() > 0) ? buildStatics.getBuildDurationSeconds() : DEFAULT_BUILD_DURATION; + 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); @@ -335,6 +351,10 @@ private ProgrammingExerciseBuildConfig loadBuildConfig(ProgrammingExercise progr return programmingExerciseBuildConfigRepository.getProgrammingExerciseBuildConfigElseThrow(programmingExercise); } + private ProgrammingExerciseBuildStatistics loadBuildStatistics(ProgrammingExercise programmingExercise) { + return programmingExerciseBuildStatisticsRepository.findByExerciseId(programmingExercise.getId()).orElse(null); + } + /** * Determines the priority of the build job. * Lower values indicate higher priority. 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 f03325c503d8..1ed01bf6e7ac 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.updateBuildAgentCapacity(); } /** @@ -323,4 +334,125 @@ public Page getFilteredFinishedBuildJobs(FinishedBuildJobPageableSearc return new PageImpl<>(orderedBuildJobs, buildJobIdsPage.getPageable(), buildJobIdsPage.getTotalElements()); } + + /** + * Estimates how long the job will be queued for on the participation ID. + * + * @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 getBuildJobEstimatedStartDate(long participationId) { + if (queue.isEmpty() || this.buildAgentsCapacity > this.runningBuildJobCount + queue.size()) { + return ZonedDateTime.now(); + } + + String buildJobId = getIdOfQueuedJobFromParticipation(participationId); + + if (buildJobId == null) { + return ZonedDateTime.now(); + } + + // Get the jobs queued before the job for the participation + List jobsQueuedBefore = getQueuedJobs().stream().sorted(new LocalCIPriorityQueueComparator()).takeWhile(job -> !job.id().equals(buildJobId)).toList(); + + ZonedDateTime now = ZonedDateTime.now(); + + // Get the remaining duration of the build jobs currently being processed + List agentsAvailabilities = new ArrayList<>(getProcessingJobs().stream().map(job -> getBuildJobRemainingDuration(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 = getQueuedJobs().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 getBuildJobRemainingDuration(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()); + updateBuildAgentCapacity(); + } + + @Override + public void entryRemoved(com.hazelcast.core.EntryEvent event) { + log.debug("Build agent removed: {}", event.getOldValue()); + updateBuildAgentCapacity(); + } + + @Override + public void entryUpdated(com.hazelcast.core.EntryEvent event) { + log.debug("Build agent updated: {}", event.getValue()); + updateBuildAgentCapacity(); + } + } + + private void updateBuildAgentCapacity() { + buildAgentsCapacity = getBuildAgentInformation().stream().mapToInt(BuildAgentInformation::maxNumberOfConcurrentBuildJobs).sum(); + runningBuildJobCount = getBuildAgentInformation().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 = getProcessingJobs().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 2566e1a87fb8..24e97cce95d2 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 @@ -39,6 +39,7 @@ import de.tum.cit.aet.artemis.exam.service.ExamService; import de.tum.cit.aet.artemis.exercise.domain.Submission; 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; @@ -57,6 +58,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 @@ -93,11 +95,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; @@ -111,6 +116,7 @@ public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipation this.studentExamRepository = studentExamRepository; this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; this.vcsAccessLogRepository = vcsAccessLogRepository; + this.sharedQueueManagementService = sharedQueueManagementService; } /** @@ -209,7 +215,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); @@ -217,9 +223,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..228995b24d87 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 start date of the build job for the given participation. + * + * @param participationId the id of the participation + * @return the estimated queue duration + */ + @GetMapping("queued-jobs/queue-duration-estimation") + @EnforceAtLeastStudent + public ResponseEntity getBuildJobEstimatedStartDate(@RequestParam long participationId) { + var start = System.nanoTime(); + if (participationId <= 0) { + ResponseEntity.badRequest().build(); + } + ZonedDateTime estimatedJobQueueReleaseTime = localCIBuildJobQueueService.getBuildJobEstimatedStartDate(participationId); + log.debug("Queue duration estimation took {} ms", TimeLogUtil.formatDurationFrom(start)); + return ResponseEntity.ok(estimatedJobQueueReleaseTime); + } + } diff --git a/src/main/resources/config/liquibase/changelog/20241101130000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241101130000_changelog.xml new file mode 100644 index 000000000000..52b413e6c87f --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241101130000_changelog.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index debf15edc981..c25b722955e3 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -34,6 +34,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..c158907edacd --- /dev/null +++ b/src/main/webapp/app/entities/programming/submission-processing-dto.ts @@ -0,0 +1,10 @@ +import dayjs from 'dayjs/esm'; + +export class SubmissionProcessingDTO { + public exerciseId?: number; + public participationId?: number; + public commitHash?: string; + public submissionDate?: dayjs.Dayjs; + public estimatedCompletionDate?: dayjs.Dayjs; + public buildStartDate?: dayjs.Dayjs; +} diff --git a/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html b/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html index 35129261f2ec..d6c4633c893f 100644 --- a/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html +++ b/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html @@ -45,6 +45,8 @@

[showBadge]="true" [participation]="studentParticipation" [personalParticipation]="true" + [showProgressBar]="true" + [showProgressBarBorder]="true" class="me-2" /> } 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..2daedf130771 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,8 @@ [participation]="participation" [personalParticipation]="true" (onParticipationChange)="receivedNewResult()" + [showProgressBar]="true" + [showProgressBarBorder]="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..4b5e60dd22d8 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,35 @@ 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; + private profileServiceSubscription: Subscription; constructor( private websocketService: JhiWebsocketService, private http: HttpClient, private participationWebsocketService: ParticipationWebsocketService, private participationService: ProgrammingExerciseParticipationService, - ) {} + private profileService: ProfileService, + ) { + this.profileServiceSubscription = 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)); + this.profileServiceSubscription.unsubscribe(); } get exerciseBuildState() { @@ -146,6 +183,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 +214,24 @@ 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) { + this.queueEstimateTimerSubscriptions[participationId]?.unsubscribe(); + } + /** * Set up a submission subscription for the latest pending submission if not yet existing. * @@ -206,8 +265,19 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi } const programmingSubmission = submission as ProgrammingSubmission; const submissionParticipationId = programmingSubmission.participation!.id!; - this.emitBuildingSubmission(submissionParticipationId, this.participationIdToExerciseId.get(submissionParticipationId)!, submission); - // 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. + let buildTimingInfo: BuildTimingInfo | undefined = undefined; + + if (this.isLocalCIProfile) { + const isSubmissionQueued = this.handleQueuedProgrammingSubmissions(programmingSubmission, submissionParticipationId); + if (isSubmissionQueued) { + return; + } + + buildTimingInfo = this.startedProcessingCache.get(programmingSubmission.commitHash!); + this.removeSubmissionFromProcessingCache(programmingSubmission.commitHash!); + } + + this.emitBuildingSubmission(submissionParticipationId, this.participationIdToExerciseId.get(submissionParticipationId)!, submission, buildTimingInfo); this.startResultWaitingTimer(submissionParticipationId); }), ) @@ -217,6 +287,97 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi } } + private handleQueuedProgrammingSubmissions(programmingSubmission: ProgrammingSubmission, submissionParticipationId: number) { + let isSubmissionQueued = false; + 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, + ); + isSubmissionQueued = true; + } + } + return isSubmissionQueued; + } + + 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) + const subscriptionOnSameTopicExists = Array.from(this.submissionProcessingTopicsSubscribed.values()).includes(newSubmissionTopic); + if (!subscriptionOnSameTopicExists) { + this.websocketService.subscribe(newSubmissionTopic); + this.websocketService + .receive(newSubmissionTopic) + .pipe( + tap((submissionProcessing: SubmissionProcessingDTO) => { + const submissionParticipationId = submissionProcessing.participationId!; + const exerciseId = this.participationIdToExerciseId.get(submissionParticipationId)!; + + if (!this.isNewestSubmission(submissionProcessing, exerciseId, submissionParticipationId)) { + return; + } + + 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 buildTimingInfo = { + estimatedCompletionDate: submissionProcessing.estimatedCompletionDate, + buildStartDate: submissionProcessing.buildStartDate, + }; + this.removeSubmissionFromProcessingCache(programmingSubmission.commitHash!); + this.resetQueueEstimateTimer(submissionParticipationId); + this.emitBuildingSubmission(submissionParticipationId, exerciseId, programmingSubmission, buildTimingInfo); + + this.startResultWaitingTimer(submissionParticipationId); + }), + ) + .subscribe(); + } + this.submissionProcessingTopicsSubscribed.set(participationId, newSubmissionTopic); + } + } + + private isNewestSubmission(newSubmission: SubmissionProcessingDTO, exerciseId: number, participationId: number): boolean { + const currentSubmission = this.exerciseBuildState[exerciseId]?.[participationId]?.submission; + + if (!currentSubmission?.submissionDate) return true; + if (!newSubmission?.submissionDate) return false; + + return dayjs(newSubmission.submissionDate).isSameOrAfter(dayjs(currentSubmission.submissionDate)); + } + + 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 +425,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 +445,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 +496,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 +708,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 +747,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 +768,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 +858,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 +886,31 @@ 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 + const isParcitipationUsingTopic = openSubscriptionsForTopic !== 0; + if (!isParcitipationUsingTopic) { + 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/exercise-headers/exercise-headers-information/exercise-headers-information.component.html b/src/main/webapp/app/exercises/shared/exercise-headers/exercise-headers-information/exercise-headers-information.component.html index 30e26e951231..9a675274ad5f 100644 --- a/src/main/webapp/app/exercises/shared/exercise-headers/exercise-headers-information/exercise-headers-information.component.html +++ b/src/main/webapp/app/exercises/shared/exercise-headers/exercise-headers-information/exercise-headers-information.component.html @@ -30,6 +30,7 @@ [triggerLastGraded]="false" [showCompletion]="false" [showBadge]="false" + [showProgressBar]="true" /> } } diff --git a/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.html b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.html new file mode 100644 index 000000000000..e3fad922db2d --- /dev/null +++ b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.html @@ -0,0 +1,35 @@ +
+
+ + @if (isQueued()) { + + } @else { + + } + @if (estimatedRemaining() && estimatedRemaining() > 0) { + {{ estimatedRemaining() | artemisDurationFromSeconds }} + } +
+
+
+
+
+
+
+
+
+
diff --git a/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.scss b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.scss new file mode 100644 index 000000000000..492978e093cc --- /dev/null +++ b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.scss @@ -0,0 +1,8 @@ +.no-transition { + transition: opacity 0.5s !important; +} + +.mw-custom { + max-width: fit-content; + min-width: 230px; +} diff --git a/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.ts b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.ts new file mode 100644 index 000000000000..b96b89855f00 --- /dev/null +++ b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.ts @@ -0,0 +1,126 @@ +import { Component, OnDestroy, effect, input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-result-progress-bar', + standalone: true, + imports: [CommonModule, ArtemisSharedCommonModule], + templateUrl: './result-progress-bar.component.html', + styleUrl: './result-progress-bar.component.scss', +}) +export class ResultProgressBarComponent implements OnDestroy { + estimatedRemaining = input(0); + estimatedDuration = input(0); + isBuilding = input.required(); + isQueued = input.required(); + showBorder = input(false); + + isQueueProgressBarAnimated: boolean; + queueProgressBarOpacity: number; + queueProgressBarValue: number; + + isBuildProgressBarAnimated: boolean; + buildProgressBarOpacity: number; + buildProgressBarValue: number; + + estimatedDurationInterval: ReturnType | undefined; + + protected readonly faCircleNotch = faCircleNotch; + + constructor() { + effect(() => { + const isBuildingOrQueued = this.cleanUpIfNotBuildingOrQueued(); + if (!isBuildingOrQueued) { + return; + } + + clearInterval(this.estimatedDurationInterval); + this.updateProgressBarState(); + }); + } + + private updateProgressBarState() { + if (this.estimatedDuration() && this.estimatedRemaining()) { + if (this.isBuilding()) { + this.setupQueueProgressBarForBuild(); + this.updateBuildProgressBar(); + } else if (this.isQueued()) { + this.setupBuildProgressBarForQueued(); + this.updateQueueProgressBar(); + } + } else { + if (this.isBuilding()) { + this.setupQueueProgressBarForBuild(); + this.isBuildProgressBarAnimated = false; + this.buildProgressBarValue = 100; + } else if (this.isQueued()) { + this.setupBuildProgressBarForQueued(); + this.isQueueProgressBarAnimated = false; + this.queueProgressBarValue = 100; + } + this.estimatedDurationInterval = setInterval(() => { + this.alternateOpacity(this.isQueued()); + }, 1000); // 1 second + } + } + + private cleanUpIfNotBuildingOrQueued() { + const isBuildingOrQueued = true; + if (!this.isBuilding() && !this.isQueued()) { + if (this.estimatedDurationInterval) { + clearInterval(this.estimatedDurationInterval); + this.estimatedDurationInterval = undefined; + } + this.isQueueProgressBarAnimated = false; + } + return isBuildingOrQueued; + } + + ngOnDestroy() { + if (this.estimatedDurationInterval) { + clearInterval(this.estimatedDurationInterval); + } + } + + private setupBuildProgressBarForQueued() { + this.isBuildProgressBarAnimated = true; + this.buildProgressBarOpacity = 1; + this.buildProgressBarValue = 0; + } + + private setupQueueProgressBarForBuild() { + this.isQueueProgressBarAnimated = true; + this.queueProgressBarOpacity = 1; + this.queueProgressBarValue = 100; + } + + private updateQueueProgressBar() { + this.isQueueProgressBarAnimated = true; + this.queueProgressBarOpacity = 1; + if (this.estimatedDuration() === 0) { + this.queueProgressBarValue = 100; + return; + } + this.queueProgressBarValue = Math.round((1 - this.estimatedRemaining() / this.estimatedDuration()) * 100); + } + + private updateBuildProgressBar() { + this.isBuildProgressBarAnimated = true; + this.buildProgressBarOpacity = 1; + if (this.estimatedDuration() === 0) { + this.buildProgressBarValue = 100; + return; + } + this.buildProgressBarValue = Math.round((1 - this.estimatedRemaining() / this.estimatedDuration()) * 100); + } + + private alternateOpacity(isQueue?: boolean) { + if (isQueue) { + this.queueProgressBarOpacity = this.queueProgressBarOpacity === 1 ? 0 : 1; + } else { + this.buildProgressBarOpacity = this.buildProgressBarOpacity === 1 ? 0 : 1; + } + } +} 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..7140b4c52e31 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.html +++ b/src/main/webapp/app/exercises/shared/result/result.component.html @@ -4,11 +4,45 @@ --> @switch (templateStatus) { + @case (ResultTemplateStatus.IS_QUEUED) { + @if (showProgressBar) { + + + } @else { + + + + @if (estimatedRemaining) { + {{ estimatedRemaining | artemisDurationFromSeconds }} + } + + } + } @case (ResultTemplateStatus.IS_BUILDING) { - - - - + @if (showProgressBar) { + + + } @else { + + + + @if (estimatedRemaining) { + {{ estimatedRemaining | artemisDurationFromSeconds }} + } + + } } @case (ResultTemplateStatus.FEEDBACK_GENERATION_FAILED) { @if (result) { 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 82613884b7cc..8a682b4a4c36 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,10 @@ 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; + @Input() showProgressBarBorder = false; textColorClass: string; resultIconClass: IconProp; @@ -74,6 +79,10 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { resultTooltip?: string; latestDueDate: dayjs.Dayjs | undefined; + estimatedDurationInterval?: ReturnType; + estimatedRemaining: number = 0; + estimatedDuration: number = 0; + // Icons readonly faCircleNotch = faCircleNotch; readonly faFile = faFile; @@ -163,6 +172,9 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { if (this.resultUpdateSubscription) { clearTimeout(this.resultUpdateSubscription); } + if (this.estimatedDurationInterval) { + clearInterval(this.estimatedDurationInterval); + } } /** @@ -175,24 +187,34 @@ 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 // ... the missingResultInfo changed // we evaluate the result status. - this.evaluate(); } + + clearInterval(this.estimatedDurationInterval); + if (this.estimatedCompletionDate && this.buildStartDate) { + this.estimatedDurationInterval = setInterval(() => { + this.estimatedRemaining = Math.max(0, dayjs(this.estimatedCompletionDate).diff(dayjs(), 'seconds')); + this.estimatedDuration = dayjs(this.estimatedCompletionDate).diff(dayjs(this.buildStartDate), 'seconds'); + }); + } } /** * 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 39541661fb38..c77cc3a9fcf2 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. @@ -154,6 +159,7 @@ export const evaluateTemplateStatus = ( result: Result | undefined, isBuilding: boolean, missingResultInfo = MissingResultInformation.NONE, + isQueued = false, ): ResultTemplateStatus => { // Fallback if participation is not set if (!participation || !exercise) { @@ -216,7 +222,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..cdd86d95b9a9 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,8 @@ [missingResultInfo]="missingResultInfo" [isInSidebarCard]="isInSidebarCard" [showCompletion]="showCompletion" + [estimatedCompletionDate]="estimatedCompletionDate" + [buildStartDate]="buildStartDate" + [showProgressBar]="showProgressBarInResult" + [showProgressBarBorder]="showProgressBarBorder" /> 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..2c38013e6a11 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,8 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { @Input() showIcon = true; @Input() isInSidebarCard = false; @Input() showCompletion = true; + @Input() showProgressBar = false; + @Input() showProgressBarBorder = 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 +47,10 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { result?: Result; isBuilding: boolean; + isQueued: boolean; + estimatedCompletionDate?: dayjs.Dayjs; + buildStartDate?: dayjs.Dayjs; + showProgressBarInResult = false; missingResultInfo = MissingResultInformation.NONE; public resultSubscription: Subscription; public submissionSubscription: Subscription; @@ -69,6 +75,10 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { this.subscribeForNewSubmissions(); } + if (this.submissionService.getIsLocalCIProfile()) { + this.showProgressBarInResult = this.showProgressBar; + } + if (this.result) { this.showResult.emit(); } @@ -134,7 +144,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, submission }) => this.updateSubmissionState(submissionState, buildTimingInfo, submission?.submissionDate)), ) .subscribe(); } @@ -169,10 +179,17 @@ 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. + * @param submissionDate the date when the submission was created. */ - private updateSubmissionState(submissionState: ProgrammingSubmissionState) { + private updateSubmissionState(submissionState: ProgrammingSubmissionState, buildTimingInfo?: BuildTimingInfo, submissionDate?: dayjs.Dayjs) { + this.isQueued = submissionState === ProgrammingSubmissionState.IS_QUEUED; this.isBuilding = submissionState === ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION; + if (this.submissionService.getIsLocalCIProfile()) { + this.updateBuildTimingInfo(submissionState, buildTimingInfo, submissionDate); + } + if (submissionState === ProgrammingSubmissionState.HAS_FAILED_SUBMISSION) { this.missingResultInfo = this.generateMissingResultInfoForFailedProgrammingExerciseSubmission(); } else { @@ -180,4 +197,32 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { this.missingResultInfo = MissingResultInformation.NONE; } } + + /** + * Updates the build timing information based on the submission state. + * + * @param submissionState - The current state of the submission. + * @param [buildTimingInfo] - Optional object containing the build start time and the estimated completion time. + * @param [submissionDate] - Optional date when the submission was created. + */ + private updateBuildTimingInfo(submissionState: ProgrammingSubmissionState, buildTimingInfo?: BuildTimingInfo, submissionDate?: dayjs.Dayjs) { + if (submissionState === ProgrammingSubmissionState.IS_QUEUED) { + this.submissionService.fetchQueueReleaseDateEstimationByParticipationId(this.participation.id!).subscribe((releaseDate) => { + if (releaseDate && !this.isBuilding) { + this.estimatedCompletionDate = releaseDate; + this.buildStartDate = submissionDate; + } + }); + } else if ( + submissionState === ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION && + buildTimingInfo && + dayjs(buildTimingInfo?.estimatedCompletionDate).isAfter(dayjs()) + ) { + this.estimatedCompletionDate = buildTimingInfo?.estimatedCompletionDate; + this.buildStartDate = buildTimingInfo?.buildStartDate; + } else { + this.estimatedCompletionDate = undefined; + this.buildStartDate = undefined; + } + } } diff --git a/src/main/webapp/app/overview/submission-result-status.component.html b/src/main/webapp/app/overview/submission-result-status.component.html index 062fe40125fa..074f1f2d8778 100644 --- a/src/main/webapp/app/overview/submission-result-status.component.html +++ b/src/main/webapp/app/overview/submission-result-status.component.html @@ -14,6 +14,7 @@ [showCompletion]="showCompletion" [short]="short" [personalParticipation]="true" + [showProgressBar]="showProgressBar" /> } @else { diff --git a/src/main/webapp/app/overview/submission-result-status.component.ts b/src/main/webapp/app/overview/submission-result-status.component.ts index 2677fcf51883..78fc9b3a0a4d 100644 --- a/src/main/webapp/app/overview/submission-result-status.component.ts +++ b/src/main/webapp/app/overview/submission-result-status.component.ts @@ -34,6 +34,7 @@ export class SubmissionResultStatusComponent implements OnChanges { @Input() showCompletion = true; @Input() short = true; @Input() triggerLastGraded = true; + @Input() showProgressBar = false; quizNotStarted: boolean; exerciseMissedDueDate: boolean; 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..eb16329d6fc5 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,11 @@ 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'; +import { ResultProgressBarComponent } from 'app/exercises/shared/result/result-progress-bar/result-progress-bar.component'; @NgModule({ - imports: [ArtemisSharedModule, ArtemisProgrammingExerciseActionsModule], + imports: [ArtemisSharedModule, ArtemisProgrammingExerciseActionsModule, ArtemisSharedComponentModule, ResultProgressBarComponent], 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..bda2a2602317 100644 --- a/src/main/webapp/i18n/de/editor.json +++ b/src/main/webapp/i18n/de/editor.json @@ -31,6 +31,8 @@ "submitDescription": "Staged, committed, pushed, kompiliert und testet Deine Änderungen.", "buildOutput": "Build Ergebnisse", "building": "Build und Tests werden ausgeführt...", + "queued": "In Warteschlange...", + "toolTip": "Abgaben werden zuerst in die Warteschlange gestellt und dann verarbeitet. Die geschätzte Zeit für jeden Schritt wird angezeigt. Bitte beachte, dass dies nur eine Schätzung ist und variieren kann.", "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..123f7231e98c 100644 --- a/src/main/webapp/i18n/en/editor.json +++ b/src/main/webapp/i18n/en/editor.json @@ -31,6 +31,8 @@ "submitDescription": "Stage, commit, push, build and test your changes.", "buildOutput": "  Build Output", "building": "Building and testing...", + "queued": "Queued...", + "toolTip": "Submissions are first queued and then processed. The estimated time for each step is displayed. Please note that this is an estimate and may 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 cdc242273761..e3979dc830fb 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; @@ -53,12 +55,16 @@ import com.hazelcast.map.IMap; import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.buildagent.dto.BuildConfig; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; +import de.tum.cit.aet.artemis.buildagent.dto.JobTimingInfo; import de.tum.cit.aet.artemis.buildagent.dto.ResultBuildJob; import de.tum.cit.aet.artemis.core.exception.VersionControlException; import de.tum.cit.aet.artemis.exercise.domain.ExerciseMode; import de.tum.cit.aet.artemis.exercise.domain.Team; +import de.tum.cit.aet.artemis.exercise.dto.SubmissionDTO; import de.tum.cit.aet.artemis.programming.AbstractProgrammingIntegrationLocalCILocalVCTestBase; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildStatistics; 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 +98,10 @@ protected String getTestPrefix() { private String commitHash; + private IQueue queuedJobs; + + private IMap processingJobs; + @BeforeAll void setupAll() { CredentialsProvider.setDefault(new UsernamePasswordCredentialsProvider(localVCUsername, localVCPassword)); @@ -121,6 +131,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 @@ -517,4 +530,55 @@ 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(); + ProgrammingExerciseBuildStatistics buildStatistics = new ProgrammingExerciseBuildStatistics(programmingExercise.getId(), 20, 100); + programmingExerciseBuildStatisticsRepository.save(buildStatistics); + + 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)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetSubmissionReturnsWhenSubmissionProcessing() throws Exception { + ProgrammingSubmission submission = (ProgrammingSubmission) new ProgrammingSubmission().submissionDate(ZonedDateTime.now().minusSeconds(61L)); + submission.setCommitHash(commitHash); + submission = programmingExerciseUtilService.addProgrammingSubmission(programmingExercise, submission, TEST_PREFIX + "student1"); + + JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now().minusSeconds(30), ZonedDateTime.now(), null, ZonedDateTime.now().plusSeconds(30), 60); + BuildConfig buildConfig = new BuildConfig(null, null, commitHash, commitHash, null, null, null, null, false, false, false, null, 0, null, null, null, null); + BuildJobQueueItem buildJobQueueItem = new BuildJobQueueItem("1", "1", null, submission.getParticipation().getId(), 1L, programmingExercise.getId(), 0, 1, null, null, + jobTimingInfo, buildConfig, null); + + processingJobs.put(buildJobQueueItem.id(), buildJobQueueItem); + var submissionDto = request.get("/api/programming-exercise-participations/" + submission.getParticipation().getId() + "/latest-pending-submission", HttpStatus.OK, + SubmissionDTO.class); + processingJobs.delete(buildJobQueueItem.id()); + + assertThat(submissionDto).isNotNull(); + assertThat(submissionDto.isProcessing()).isTrue(); + assertThat(submissionDto.buildStartDate()).isNotNull(); + assertThat(submissionDto.estimatedCompletionDate()).isNotNull(); + } } 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 90622c6f808c..8ab4e99eb38e 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, 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, 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); @@ -389,4 +391,38 @@ void testPauseAllBuildAgents() throws Exception { return agents.stream().allMatch(agent -> agent.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, 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.getBuildJobEstimatedStartDate(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 9901886e5c42..ef4e012901da 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, null); RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java index f0dfa2fe983c..cbdd0bcc9cf2 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java @@ -52,6 +52,7 @@ import de.tum.cit.aet.artemis.programming.icl.LocalVCLocalCITestService; import de.tum.cit.aet.artemis.programming.icl.TestBuildAgentConfiguration; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildStatisticsRepository; import de.tum.cit.aet.artemis.programming.repository.SolutionProgrammingExerciseParticipationRepository; import de.tum.cit.aet.artemis.programming.repository.TemplateProgrammingExerciseParticipationRepository; import de.tum.cit.aet.artemis.programming.service.ProgrammingMessagingService; @@ -104,6 +105,9 @@ public abstract class AbstractSpringIntegrationLocalCILocalVCTest extends Abstra @Autowired protected ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository; + @Autowired + protected ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository; + @Autowired protected TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository; diff --git a/src/test/javascript/spec/component/shared/result-progress-bar.component.spec.ts b/src/test/javascript/spec/component/shared/result-progress-bar.component.spec.ts new file mode 100644 index 000000000000..b4d97307071d --- /dev/null +++ b/src/test/javascript/spec/component/shared/result-progress-bar.component.spec.ts @@ -0,0 +1,112 @@ +import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; +import { ResultProgressBarComponent } from '../../../../../main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component'; +import { ArtemisTestModule } from '../../test.module'; + +describe('ResultProgressBarComponent', () => { + let component: ResultProgressBarComponent; + let fixture: ComponentFixture; + let clearIntervalSpy: jest.SpyInstance; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResultProgressBarComponent, ArtemisTestModule], + }).compileComponents(); + + fixture = TestBed.createComponent(ResultProgressBarComponent); + component = fixture.componentInstance; + + clearIntervalSpy = jest.spyOn(window, 'clearInterval'); + + fixture.componentRef.setInput('estimatedRemaining', 10); + fixture.componentRef.setInput('estimatedDuration', 20); + fixture.componentRef.setInput('isBuilding', false); + fixture.componentRef.setInput('isQueued', true); + + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should clear interval when not queued or building', fakeAsync(() => { + fixture.componentRef.setInput('isBuilding', false); + fixture.componentRef.setInput('isQueued', false); + fixture.detectChanges(); + + expect(clearIntervalSpy).toHaveBeenCalledWith(component.estimatedDurationInterval); + })); + + it('should set queue progress bar', fakeAsync(() => { + jest.useFakeTimers(); + fixture.componentRef.setInput('estimatedDuration', 25); + fixture.detectChanges(); + + expect(component.isBuildProgressBarAnimated).toBeTrue(); + expect(component.buildProgressBarOpacity).toBe(1); + expect(component.buildProgressBarValue).toBe(0); + + jest.advanceTimersByTime(1500); + + expect(component.queueProgressBarValue).toBeGreaterThan(0); + expect(component.queueProgressBarValue).toBeLessThan(100); + })); + + it('should set build progress bar', fakeAsync(() => { + jest.useFakeTimers(); + fixture.componentRef.setInput('isBuilding', true); + fixture.componentRef.setInput('isQueued', false); + fixture.detectChanges(); + + expect(component.isQueueProgressBarAnimated).toBeTrue(); + expect(component.queueProgressBarOpacity).toBe(1); + expect(component.queueProgressBarValue).toBe(100); + + jest.advanceTimersByTime(1500); + + expect(component.buildProgressBarValue).toBeGreaterThan(0); + expect(component.buildProgressBarValue).toBeLessThan(100); + })); + + it('should alternate opacity when queued', fakeAsync(() => { + component.queueProgressBarOpacity = 1; + + jest.useFakeTimers(); + fixture.componentRef.setInput('isBuilding', false); + fixture.componentRef.setInput('isQueued', true); + fixture.componentRef.setInput('estimatedDuration', undefined); + fixture.componentRef.setInput('estimatedRemaining', undefined); + fixture.detectChanges(); + + expect(component.isQueueProgressBarAnimated).toBeFalse(); + expect(component.queueProgressBarValue).toBe(100); + + jest.advanceTimersByTime(1500); + + expect(component.queueProgressBarOpacity).toBe(0); + })); + + it('should alternate opacity when building', fakeAsync(() => { + component.buildProgressBarOpacity = 1; + + jest.useFakeTimers(); + fixture.componentRef.setInput('isBuilding', true); + fixture.componentRef.setInput('isQueued', false); + fixture.componentRef.setInput('estimatedDuration', undefined); + fixture.componentRef.setInput('estimatedRemaining', undefined); + fixture.detectChanges(); + + expect(component.isBuildProgressBarAnimated).toBeFalse(); + expect(component.buildProgressBarValue).toBe(100); + + jest.advanceTimersByTime(1500); + + expect(component.buildProgressBarOpacity).toBe(0); + })); + + it('should clear interval on destroy', fakeAsync(() => { + component.ngOnDestroy(); + expect(clearIntervalSpy).toHaveBeenCalledWith(component.estimatedDurationInterval); + })); +}); diff --git a/src/test/javascript/spec/component/shared/result.component.spec.ts b/src/test/javascript/spec/component/shared/result.component.spec.ts index 0ceee5f6a787..4bf9b19beb03 100644 --- a/src/test/javascript/spec/component/shared/result.component.spec.ts +++ b/src/test/javascript/spec/component/shared/result.component.spec.ts @@ -399,4 +399,20 @@ describe('ResultComponent', () => { expect(comp.templateStatus).toEqual(ResultTemplateStatus.HAS_RESULT); expect(comp.resultTooltip).toContain('artemisApp.result.resultString.automaticAIFeedbackSuccessfulTooltip'); }); + + it('should trigger Interval creation on estimatedCompletionDate change', () => { + jest.useFakeTimers(); + comp.buildStartDate = dayjs().subtract(20, 'seconds'); + comp.estimatedCompletionDate = dayjs().add(20, 'seconds'); + comp.ngOnChanges({}); + + jest.advanceTimersByTime(1200); + expect(comp.estimatedDurationInterval).toBeDefined(); + expect(comp.estimatedRemaining).toBeGreaterThan(0); + expect(comp.estimatedRemaining).toBeLessThan(40); + expect(comp.estimatedDuration).toBe(40); + + jest.clearAllTimers(); + jest.useRealTimers(); + }); }); 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..2cec23b11bbd 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, @@ -17,9 +18,12 @@ import { MockParticipationWebsocketService } from '../helpers/mocks/service/mock import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; import { MockProgrammingExerciseParticipationService } from '../helpers/mocks/service/mock-programming-exercise-participation.service'; import { HttpClient, provideHttpClient } from '@angular/common/http'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { TestBed, discardPeriodicTasks, 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,46 @@ 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') }; + 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; + mockSubmissionProcessingDTO = { + exerciseId: exerciseId, + participationId: participationId, + commitHash: 'abc123', + estimatedCompletionDate: buildTimingInfo.estimatedCompletionDate, + buildStartDate: buildTimingInfo.buildStartDate, + submissionDate: dayjs().subtract(20, 'seconds'), + }; + mockSubmissionProcessingDTOOld = { + ...mockSubmissionProcessingDTO, + commitHash: 'abc123Old', + submissionDate: dayjs().subtract(40, 'seconds'), + }; TestBed.configureTestingModule({ imports: [], @@ -61,6 +91,7 @@ describe('ProgrammingSubmissionService', () => { { provide: JhiWebsocketService, useClass: MockWebsocketService }, { provide: ParticipationWebsocketService, useClass: MockParticipationWebsocketService }, { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, + { provide: ProfileService, useClass: MockProfileService }, ], }) .compileComponents() @@ -76,7 +107,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 +149,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 +165,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 +206,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 +443,148 @@ 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, + }, + ]); + }); + + it('should change to building when queue timer ends', fakeAsync(() => { + // @ts-ignore + submissionService.currentExpectedQueueEstimate = 1000; + 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. + currentProgrammingSubmission.submissionDate = dayjs(); + wsSubmissionSubject.next(currentProgrammingSubmission); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + ]); + + tick(1000); + + 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 }, + ]); + + 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 }, + ]); + + discardPeriodicTasks(); + })); });