diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java index d091100..e468b09 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java @@ -13,16 +13,20 @@ import nl.andrewlalis.gymboard_api.domains.submission.model.Submission; import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import nl.andrewlalis.gymboard_api.domains.submission.model.SubmissionProperties; import nl.andrewlalis.gymboard_api.util.ULID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; +import java.time.Duration; import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; /** * Service which handles the rather mundane tasks associated with exercise @@ -89,17 +93,23 @@ public class ExerciseSubmissionService { if (weightUnit == WeightUnit.POUNDS) { metricWeight = WeightUnit.toKilograms(rawWeight); } - Submission submission = submissionRepository.saveAndFlush(new Submission( - ulid.nextULID(), gym, exercise, user, + SubmissionProperties properties = new SubmissionProperties( + exercise, performedAt, - payload.taskId(), - rawWeight, weightUnit, metricWeight, payload.reps() - )); + rawWeight, + weightUnit, + payload.reps() + ); + + Submission submission = new Submission(ulid.nextULID(), gym, user, payload.taskId(), properties); try { cdnClient.uploads.startTask(submission.getVideoProcessingTaskId()); + submission.setProcessing(true); } catch (Exception e) { - log.error("Failed to start video processing task for submission " + submission.getId(), e); + log.error("Failed to start video processing task for submission.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to start video processing."); } + submission = submissionRepository.save(submission); return new SubmissionResponse(submission); } @@ -125,7 +135,7 @@ public class ExerciseSubmissionService { try { var status = cdnClient.uploads.getVideoProcessingTaskStatus(data.taskId()); - if (!status.status().equalsIgnoreCase("NOT_STARTED")) { + if (status == null || !status.status().equalsIgnoreCase("NOT_STARTED")) { response.addMessage("Invalid video processing task."); } } catch (Exception e) { @@ -156,6 +166,14 @@ public class ExerciseSubmissionService { submissionRepository.delete(submission); } + /** + * This method is invoked when the CDN calls this API's endpoint to notify + * us that a video processing task has completed. If the task completed + * successfully, we can set any related submissions' video and thumbnail + * file ids and remove its "processing" flag. Otherwise, we should delete + * the failed submission. + * @param payload The information about the task. + */ @Transactional public void handleVideoProcessingComplete(VideoProcessingCompletePayload payload) { var submissionsToUpdate = submissionRepository.findUnprocessedByTaskId(payload.taskId()); @@ -164,6 +182,7 @@ public class ExerciseSubmissionService { if (payload.status().equalsIgnoreCase("COMPLETE")) { submission.setVideoFileId(payload.videoFileId()); submission.setThumbnailFileId(payload.thumbnailFileId()); + submission.setProcessing(false); submissionRepository.save(submission); // TODO: Send notification of successful processing to the user! } else if (payload.status().equalsIgnoreCase("FAILED")) { @@ -172,4 +191,94 @@ public class ExerciseSubmissionService { } } } + + /** + * A scheduled task that checks and resolves issues with any submission that + * stays in the "processing" state for too long. + * TODO: Find some way to clean up this mess of logic! + */ + @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES) + public void checkProcessingSubmissions() { + var processingSubmissions = submissionRepository.findAllByProcessingTrue(); + LocalDateTime actionCutoff = LocalDateTime.now().minus(Duration.ofMinutes(10)); + LocalDateTime deleteCutoff = LocalDateTime.now().minus(Duration.ofMinutes(30)); + for (var submission : processingSubmissions) { + if (submission.getCreatedAt().isBefore(actionCutoff)) { + // Sanity check to remove any inconsistent submission that doesn't have a task id for whatever reason. + if (submission.getVideoProcessingTaskId() == null) { + log.warn( + "Removing long-processing submission {} for user {} because it doesn't have a task id.", + submission.getId(), submission.getUser().getEmail() + ); + submissionRepository.delete(submission); + // TODO: Send notification to user. + continue; + } + + try { + var status = cdnClient.uploads.getVideoProcessingTaskStatus(submission.getVideoProcessingTaskId()); + if (status == null) { + // The task no longer exists on the CDN, so remove the submission. + log.warn( + "Removing long-processing submission {} for user {} because its task no longer exists on the CDN.", + submission.getId(), submission.getUser().getEmail() + ); + submissionRepository.delete(submission); + // TODO: Send notification to user. + } else if (status.status().equalsIgnoreCase("FAILED")) { + // The task failed, so we should remove the submission. + log.warn( + "Removing long-processing submission {} for user {} because its task failed.", + submission.getId(), submission.getUser().getEmail() + ); + submissionRepository.delete(submission); + // TODO: Send notification to user. + } else if (status.status().equalsIgnoreCase("COMPLETED")) { + // The submission should be marked as complete. + submission.setVideoFileId(status.videoFileId()); + submission.setThumbnailFileId(status.thumbnailFileId()); + submission.setProcessing(false); + submissionRepository.save(submission); + // TODO: Send notification to user. + } else if (status.status().equalsIgnoreCase("NOT_STARTED")) { + // If for whatever reason the submission's video processing never started, start now. + try { + cdnClient.uploads.startTask(submission.getVideoProcessingTaskId()); + } catch (Exception e) { + log.error("Failed to start processing task " + submission.getVideoProcessingTaskId(), e); + if (submission.getCreatedAt().isBefore(deleteCutoff)) { + log.warn( + "Removing long-processing submission {} for user {} because it is waiting or processing for too long.", + submission.getId(), submission.getUser().getEmail() + ); + submissionRepository.delete(submission); + // TODO: Send notification to user. + } + } + } else { + // The task is waiting or processing, so delete the submission if it's been in that state for an unreasonably long time. + if (submission.getCreatedAt().isBefore(deleteCutoff)) { + log.warn( + "Removing long-processing submission {} for user {} because it is waiting or processing for too long.", + submission.getId(), submission.getUser().getEmail() + ); + submissionRepository.delete(submission); + // TODO: Send notification to user. + } + } + } catch (Exception e) { + log.error("Couldn't fetch status of long-processing submission " + submission.getId() + " for user " + submission.getUser().getEmail(), e); + // We can't reliably remove this submission yet, so we'll try again on the next pass. + if (submission.getCreatedAt().isBefore(deleteCutoff)) { + log.warn( + "Removing long-processing submission {} for user {} because it is waiting or processing for too long.", + submission.getId(), submission.getUser().getEmail() + ); + submissionRepository.delete(submission); + // TODO: Send notification to user. + } + } + } + } + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/dao/SubmissionRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/dao/SubmissionRepository.java index cb433f2..602e6bb 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/dao/SubmissionRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/dao/SubmissionRepository.java @@ -17,6 +17,8 @@ public interface SubmissionRepository extends JpaRepository, @Query("SELECT s FROM Submission s " + "WHERE s.videoProcessingTaskId = :taskId AND " + - "(s.videoFileId IS NULL OR s.thumbnailFileId IS NULL)") + "s.processing = TRUE") List findUnprocessedByTaskId(long taskId); + + List findAllByProcessingTrue(); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/dto/SubmissionResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/dto/SubmissionResponse.java index 0207699..0800295 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/dto/SubmissionResponse.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/dto/SubmissionResponse.java @@ -10,34 +10,39 @@ public record SubmissionResponse( String id, String createdAt, GymSimpleResponse gym, - ExerciseResponse exercise, UserResponse user, - String performedAt, long videoProcessingTaskId, String videoFileId, String thumbnailFileId, + boolean processing, + boolean verified, + + // From SubmissionProperties + ExerciseResponse exercise, + String performedAt, double rawWeight, String weightUnit, double metricWeight, - int reps, - boolean verified + int reps ) { public SubmissionResponse(Submission submission) { this( submission.getId(), StandardDateFormatter.format(submission.getCreatedAt()), new GymSimpleResponse(submission.getGym()), - new ExerciseResponse(submission.getExercise()), new UserResponse(submission.getUser()), - StandardDateFormatter.format(submission.getPerformedAt()), submission.getVideoProcessingTaskId(), submission.getVideoFileId(), submission.getThumbnailFileId(), - submission.getRawWeight().doubleValue(), - submission.getWeightUnit().name(), - submission.getMetricWeight().doubleValue(), - submission.getReps(), - submission.isVerified() + submission.isProcessing(), + submission.isVerified(), + + new ExerciseResponse(submission.getProperties().getExercise()), + StandardDateFormatter.format(submission.getCreatedAt()), + submission.getProperties().getRawWeight().doubleValue(), + submission.getProperties().getWeightUnit().name(), + submission.getProperties().getMetricWeight().doubleValue(), + submission.getProperties().getReps() ); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/Submission.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/Submission.java index 0a78ced..8ea3137 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/Submission.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/Submission.java @@ -1,15 +1,33 @@ package nl.andrewlalis.gymboard_api.domains.submission.model; import jakarta.persistence.*; -import nl.andrewlalis.gymboard_api.domains.api.model.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.Gym; -import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; import nl.andrewlalis.gymboard_api.domains.auth.model.User; import org.hibernate.annotations.CreationTimestamp; -import java.math.BigDecimal; import java.time.LocalDateTime; +/** + * The Submission entity represents a user's posted video of a lift they did at + * a gym. + *

+ * A submission is created in the front-end using the following flow: + *

+ *
    + *
  1. User uploads a raw video of their lift.
  2. + *
  3. User enters some basic information about the lift.
  4. + *
  5. User submits the lift.
  6. + *
  7. API validates the information.
  8. + *
  9. API creates a new Submission, and tells the CDN service to process + * the uploaded video.
  10. + *
  11. Once processing completes successfully, the CDN sends the final video + * and thumbnail file ids to the API and the Submission's "processing" flag + * is removed.
  12. + *
  13. If for whatever reason the CDN's video processing fails or never + * completes, the Submission is deleted and the user is notified of the + * issue.
  14. + *
+ */ @Entity @Table(name = "submission") public class Submission { @@ -23,22 +41,16 @@ public class Submission { @ManyToOne(optional = false, fetch = FetchType.LAZY) private Gym gym; - @ManyToOne(optional = false, fetch = FetchType.LAZY) - private Exercise exercise; - @ManyToOne(optional = false, fetch = FetchType.LAZY) private User user; - @Column(nullable = false) - private LocalDateTime performedAt; - /** * The id of the video processing task that a user gives to us when they * create the submission, so that when the task finishes processing, we can * route its data to the right submission. */ - @Column(nullable = false, updatable = false) - private long videoProcessingTaskId; + @Column + private Long videoProcessingTaskId; /** * The id of the video file that was submitted for this submission. It lives @@ -55,18 +67,19 @@ public class Submission { @Column(length = 26) private String thumbnailFileId = null; - @Column(nullable = false, precision = 7, scale = 2) - private BigDecimal rawWeight; + /** + * The user-specified properties of the submission. + */ + @Embedded + private SubmissionProperties properties; - @Enumerated(EnumType.STRING) + /** + * A flag that indicates whether this submission is currently processing. + * A submission is processing until its associated processing task completes + * either successfully or unsuccessfully. + */ @Column(nullable = false) - private WeightUnit weightUnit; - - @Column(nullable = false, precision = 7, scale = 2) - private BigDecimal metricWeight; - - @Column(nullable = false) - private int reps; + private boolean processing; @Column(nullable = false) private boolean verified; @@ -76,25 +89,15 @@ public class Submission { public Submission( String id, Gym gym, - Exercise exercise, User user, - LocalDateTime performedAt, long videoProcessingTaskId, - BigDecimal rawWeight, - WeightUnit unit, - BigDecimal metricWeight, - int reps + SubmissionProperties properties ) { this.id = id; this.gym = gym; - this.exercise = exercise; this.user = user; - this.performedAt = performedAt; this.videoProcessingTaskId = videoProcessingTaskId; - this.rawWeight = rawWeight; - this.weightUnit = unit; - this.metricWeight = metricWeight; - this.reps = reps; + this.properties = properties; this.verified = false; } @@ -110,11 +113,7 @@ public class Submission { return gym; } - public Exercise getExercise() { - return exercise; - } - - public long getVideoProcessingTaskId() { + public Long getVideoProcessingTaskId() { return videoProcessingTaskId; } @@ -138,24 +137,16 @@ public class Submission { return user; } - public LocalDateTime getPerformedAt() { - return performedAt; + public SubmissionProperties getProperties() { + return properties; } - public BigDecimal getRawWeight() { - return rawWeight; + public boolean isProcessing() { + return processing; } - public WeightUnit getWeightUnit() { - return weightUnit; - } - - public BigDecimal getMetricWeight() { - return metricWeight; - } - - public int getReps() { - return reps; + public void setProcessing(boolean processing) { + this.processing = processing; } public boolean isVerified() { diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/SubmissionDraft.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/SubmissionDraft.java deleted file mode 100644 index f9f47eb..0000000 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/SubmissionDraft.java +++ /dev/null @@ -1,129 +0,0 @@ -package nl.andrewlalis.gymboard_api.domains.submission.model; - -import jakarta.persistence.*; -import nl.andrewlalis.gymboard_api.domains.api.model.Exercise; -import nl.andrewlalis.gymboard_api.domains.api.model.Gym; -import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; -import nl.andrewlalis.gymboard_api.domains.auth.model.User; -import org.hibernate.annotations.CreationTimestamp; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * A submission draft is a temporary entity that exists while a user is - * preparing their submission. It includes all the data needed to make a - * submission, so when the user has finished editing, they can "submit" their - * draft and video processing will then begin, and once done, their submission - * will be published. - *

- * This is not yet implemented! - *

- */ -@Entity -@Table(name = "submission_draft") -public class SubmissionDraft { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @CreationTimestamp - private LocalDateTime createdAt; - - @ManyToOne(optional = false, fetch = FetchType.LAZY) - private User user; - - @ManyToOne(optional = false, fetch = FetchType.LAZY) - private Gym gym; - - // All of the following properties are editable while this draft has not yet - // been submitted. They will be validated upon submission. - - @ManyToOne(fetch = FetchType.LAZY) - private Exercise exercise; - - @Column - private LocalDateTime performedAt; - - @Column(precision = 7, scale = 2) - private BigDecimal rawWeight; - - @Column @Enumerated(EnumType.STRING) - private WeightUnit weightUnit; - - @Column - private int reps; - - @Column - private long videoProcessingTaskId; - - public SubmissionDraft() {} - - public SubmissionDraft(User user, Gym gym) { - this.user = user; - this.gym = gym; - } - - public Long getId() { - return id; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public User getUser() { - return user; - } - - public Gym getGym() { - return gym; - } - - public Exercise getExercise() { - return exercise; - } - - public LocalDateTime getPerformedAt() { - return performedAt; - } - - public BigDecimal getRawWeight() { - return rawWeight; - } - - public WeightUnit getWeightUnit() { - return weightUnit; - } - - public int getReps() { - return reps; - } - - public long getVideoProcessingTaskId() { - return videoProcessingTaskId; - } - - public void setExercise(Exercise exercise) { - this.exercise = exercise; - } - - public void setPerformedAt(LocalDateTime performedAt) { - this.performedAt = performedAt; - } - - public void setRawWeight(BigDecimal rawWeight) { - this.rawWeight = rawWeight; - } - - public void setWeightUnit(WeightUnit weightUnit) { - this.weightUnit = weightUnit; - } - - public void setReps(int reps) { - this.reps = reps; - } - - public void setVideoProcessingTaskId(long videoProcessingTaskId) { - this.videoProcessingTaskId = videoProcessingTaskId; - } -} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/SubmissionProperties.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/SubmissionProperties.java new file mode 100644 index 0000000..2096735 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/submission/model/SubmissionProperties.java @@ -0,0 +1,74 @@ +package nl.andrewlalis.gymboard_api.domains.submission.model; + +import jakarta.persistence.*; +import nl.andrewlalis.gymboard_api.domains.api.model.Exercise; +import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Basic user-specified properties about a Submission. + */ +@Embeddable +public class SubmissionProperties { + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private Exercise exercise; + + @Column(nullable = false) + private LocalDateTime performedAt; + + @Column(nullable = false, precision = 7, scale = 2) + private BigDecimal rawWeight; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private WeightUnit weightUnit; + + @Column(nullable = false, precision = 7, scale = 2) + private BigDecimal metricWeight; + + @Column(nullable = false) + private int reps; + + public SubmissionProperties() {} + + public SubmissionProperties( + Exercise exercise, + LocalDateTime performedAt, + BigDecimal rawWeight, + WeightUnit weightUnit, + int reps + ) { + this.exercise = exercise; + this.performedAt = performedAt; + this.rawWeight = rawWeight; + this.weightUnit = weightUnit; + this.metricWeight = WeightUnit.toKilograms(rawWeight, weightUnit); + this.reps = reps; + } + + public Exercise getExercise() { + return exercise; + } + + public LocalDateTime getPerformedAt() { + return performedAt; + } + + public BigDecimal getRawWeight() { + return rawWeight; + } + + public WeightUnit getWeightUnit() { + return weightUnit; + } + + public BigDecimal getMetricWeight() { + return metricWeight; + } + + public int getReps() { + return reps; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java index 3701be0..dedd5e7 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java @@ -11,6 +11,7 @@ import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient; import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.UploadsClient; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import nl.andrewlalis.gymboard_api.domains.submission.model.SubmissionProperties; import nl.andrewlalis.gymboard_api.util.ULID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,6 +51,8 @@ public class SampleSubmissionGenerator implements SampleDataGenerator { @Override public void generate() throws Exception { + // First we generate a small set of uploaded files that all the + // submissions can link to, instead of having them all upload new content. var uploads = generateUploads(); // Now that uploads are complete, we can proceed with generating the submissions. @@ -76,8 +79,6 @@ public class SampleSubmissionGenerator implements SampleDataGenerator { submissions.add(submission); } submissionRepository.saveAll(submissions); - - // After adding all the submissions, we'll signal to CDN that it can start processing. } private Submission generateRandomSubmission( @@ -97,20 +98,23 @@ public class SampleSubmissionGenerator implements SampleDataGenerator { weightUnit = WeightUnit.POUNDS; rawWeight = metricWeight.multiply(new BigDecimal("2.2046226218")); } + SubmissionProperties properties = new SubmissionProperties( + randomChoice(exercises, random), + time, + rawWeight, + weightUnit, + random.nextInt(13) + 1 + ); var submission = new Submission( ulid.nextULID(), randomChoice(gyms, random), - randomChoice(exercises, random), randomChoice(users, random), - time, randomChoice(new ArrayList<>(uploads.keySet()), random), - rawWeight, - weightUnit, - metricWeight, - random.nextInt(13) + 1 + properties ); submission.setVerified(true); + submission.setProcessing(false); var uploadData = uploads.get(submission.getVideoProcessingTaskId()); submission.setVideoFileId(uploadData.getFirst()); submission.setThumbnailFileId(uploadData.getSecond());