Add more submission logic.

This commit is contained in:
Andrew Lalis 2023-04-07 00:05:40 +02:00
parent 1ade7ffe66
commit 52be976286
7 changed files with 264 additions and 208 deletions

View File

@ -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.
}
}
}
}
}
}

View File

@ -17,6 +17,8 @@ public interface SubmissionRepository extends JpaRepository<Submission, String>,
@Query("SELECT s FROM Submission s " +
"WHERE s.videoProcessingTaskId = :taskId AND " +
"(s.videoFileId IS NULL OR s.thumbnailFileId IS NULL)")
"s.processing = TRUE")
List<Submission> findUnprocessedByTaskId(long taskId);
List<Submission> findAllByProcessingTrue();
}

View File

@ -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()
);
}
}

View File

@ -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.
* <p>
* A submission is created in the front-end using the following flow:
* </p>
* <ol>
* <li>User uploads a raw video of their lift.</li>
* <li>User enters some basic information about the lift.</li>
* <li>User submits the lift.</li>
* <li>API validates the information.</li>
* <li>API creates a new Submission, and tells the CDN service to process
* the uploaded video.</li>
* <li>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.</li>
* <li>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.</li>
* </ol>
*/
@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() {

View File

@ -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.
* <p>
* <strong>This is not yet implemented!</strong>
* </p>
*/
@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;
}
}

View File

@ -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;
}
}

View File

@ -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());