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.api.service.cdn_client.CdnClient;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
import nl.andrewlalis.gymboard_api.domains.auth.model.User; 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 nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/** /**
* Service which handles the rather mundane tasks associated with exercise * Service which handles the rather mundane tasks associated with exercise
@ -89,17 +93,23 @@ public class ExerciseSubmissionService {
if (weightUnit == WeightUnit.POUNDS) { if (weightUnit == WeightUnit.POUNDS) {
metricWeight = WeightUnit.toKilograms(rawWeight); metricWeight = WeightUnit.toKilograms(rawWeight);
} }
Submission submission = submissionRepository.saveAndFlush(new Submission( SubmissionProperties properties = new SubmissionProperties(
ulid.nextULID(), gym, exercise, user, exercise,
performedAt, performedAt,
payload.taskId(), rawWeight,
rawWeight, weightUnit, metricWeight, payload.reps() weightUnit,
)); payload.reps()
);
Submission submission = new Submission(ulid.nextULID(), gym, user, payload.taskId(), properties);
try { try {
cdnClient.uploads.startTask(submission.getVideoProcessingTaskId()); cdnClient.uploads.startTask(submission.getVideoProcessingTaskId());
submission.setProcessing(true);
} catch (Exception e) { } 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); return new SubmissionResponse(submission);
} }
@ -125,7 +135,7 @@ public class ExerciseSubmissionService {
try { try {
var status = cdnClient.uploads.getVideoProcessingTaskStatus(data.taskId()); 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."); response.addMessage("Invalid video processing task.");
} }
} catch (Exception e) { } catch (Exception e) {
@ -156,6 +166,14 @@ public class ExerciseSubmissionService {
submissionRepository.delete(submission); 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 @Transactional
public void handleVideoProcessingComplete(VideoProcessingCompletePayload payload) { public void handleVideoProcessingComplete(VideoProcessingCompletePayload payload) {
var submissionsToUpdate = submissionRepository.findUnprocessedByTaskId(payload.taskId()); var submissionsToUpdate = submissionRepository.findUnprocessedByTaskId(payload.taskId());
@ -164,6 +182,7 @@ public class ExerciseSubmissionService {
if (payload.status().equalsIgnoreCase("COMPLETE")) { if (payload.status().equalsIgnoreCase("COMPLETE")) {
submission.setVideoFileId(payload.videoFileId()); submission.setVideoFileId(payload.videoFileId());
submission.setThumbnailFileId(payload.thumbnailFileId()); submission.setThumbnailFileId(payload.thumbnailFileId());
submission.setProcessing(false);
submissionRepository.save(submission); submissionRepository.save(submission);
// TODO: Send notification of successful processing to the user! // TODO: Send notification of successful processing to the user!
} else if (payload.status().equalsIgnoreCase("FAILED")) { } 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 " + @Query("SELECT s FROM Submission s " +
"WHERE s.videoProcessingTaskId = :taskId AND " + "WHERE s.videoProcessingTaskId = :taskId AND " +
"(s.videoFileId IS NULL OR s.thumbnailFileId IS NULL)") "s.processing = TRUE")
List<Submission> findUnprocessedByTaskId(long taskId); List<Submission> findUnprocessedByTaskId(long taskId);
List<Submission> findAllByProcessingTrue();
} }

View File

@ -10,34 +10,39 @@ public record SubmissionResponse(
String id, String id,
String createdAt, String createdAt,
GymSimpleResponse gym, GymSimpleResponse gym,
ExerciseResponse exercise,
UserResponse user, UserResponse user,
String performedAt,
long videoProcessingTaskId, long videoProcessingTaskId,
String videoFileId, String videoFileId,
String thumbnailFileId, String thumbnailFileId,
boolean processing,
boolean verified,
// From SubmissionProperties
ExerciseResponse exercise,
String performedAt,
double rawWeight, double rawWeight,
String weightUnit, String weightUnit,
double metricWeight, double metricWeight,
int reps, int reps
boolean verified
) { ) {
public SubmissionResponse(Submission submission) { public SubmissionResponse(Submission submission) {
this( this(
submission.getId(), submission.getId(),
StandardDateFormatter.format(submission.getCreatedAt()), StandardDateFormatter.format(submission.getCreatedAt()),
new GymSimpleResponse(submission.getGym()), new GymSimpleResponse(submission.getGym()),
new ExerciseResponse(submission.getExercise()),
new UserResponse(submission.getUser()), new UserResponse(submission.getUser()),
StandardDateFormatter.format(submission.getPerformedAt()),
submission.getVideoProcessingTaskId(), submission.getVideoProcessingTaskId(),
submission.getVideoFileId(), submission.getVideoFileId(),
submission.getThumbnailFileId(), submission.getThumbnailFileId(),
submission.getRawWeight().doubleValue(), submission.isProcessing(),
submission.getWeightUnit().name(), submission.isVerified(),
submission.getMetricWeight().doubleValue(),
submission.getReps(), new ExerciseResponse(submission.getProperties().getExercise()),
submission.isVerified() 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; package nl.andrewlalis.gymboard_api.domains.submission.model;
import jakarta.persistence.*; 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.Gym;
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
import nl.andrewlalis.gymboard_api.domains.auth.model.User; import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime; 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 @Entity
@Table(name = "submission") @Table(name = "submission")
public class Submission { public class Submission {
@ -23,22 +41,16 @@ public class Submission {
@ManyToOne(optional = false, fetch = FetchType.LAZY) @ManyToOne(optional = false, fetch = FetchType.LAZY)
private Gym gym; private Gym gym;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private Exercise exercise;
@ManyToOne(optional = false, fetch = FetchType.LAZY) @ManyToOne(optional = false, fetch = FetchType.LAZY)
private User user; private User user;
@Column(nullable = false)
private LocalDateTime performedAt;
/** /**
* The id of the video processing task that a user gives to us when they * 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 * create the submission, so that when the task finishes processing, we can
* route its data to the right submission. * route its data to the right submission.
*/ */
@Column(nullable = false, updatable = false) @Column
private long videoProcessingTaskId; private Long videoProcessingTaskId;
/** /**
* The id of the video file that was submitted for this submission. It lives * The id of the video file that was submitted for this submission. It lives
@ -55,18 +67,19 @@ public class Submission {
@Column(length = 26) @Column(length = 26)
private String thumbnailFileId = null; 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) @Column(nullable = false)
private WeightUnit weightUnit; private boolean processing;
@Column(nullable = false, precision = 7, scale = 2)
private BigDecimal metricWeight;
@Column(nullable = false)
private int reps;
@Column(nullable = false) @Column(nullable = false)
private boolean verified; private boolean verified;
@ -76,25 +89,15 @@ public class Submission {
public Submission( public Submission(
String id, String id,
Gym gym, Gym gym,
Exercise exercise,
User user, User user,
LocalDateTime performedAt,
long videoProcessingTaskId, long videoProcessingTaskId,
BigDecimal rawWeight, SubmissionProperties properties
WeightUnit unit,
BigDecimal metricWeight,
int reps
) { ) {
this.id = id; this.id = id;
this.gym = gym; this.gym = gym;
this.exercise = exercise;
this.user = user; this.user = user;
this.performedAt = performedAt;
this.videoProcessingTaskId = videoProcessingTaskId; this.videoProcessingTaskId = videoProcessingTaskId;
this.rawWeight = rawWeight; this.properties = properties;
this.weightUnit = unit;
this.metricWeight = metricWeight;
this.reps = reps;
this.verified = false; this.verified = false;
} }
@ -110,11 +113,7 @@ public class Submission {
return gym; return gym;
} }
public Exercise getExercise() { public Long getVideoProcessingTaskId() {
return exercise;
}
public long getVideoProcessingTaskId() {
return videoProcessingTaskId; return videoProcessingTaskId;
} }
@ -138,24 +137,16 @@ public class Submission {
return user; return user;
} }
public LocalDateTime getPerformedAt() { public SubmissionProperties getProperties() {
return performedAt; return properties;
} }
public BigDecimal getRawWeight() { public boolean isProcessing() {
return rawWeight; return processing;
} }
public WeightUnit getWeightUnit() { public void setProcessing(boolean processing) {
return weightUnit; this.processing = processing;
}
public BigDecimal getMetricWeight() {
return metricWeight;
}
public int getReps() {
return reps;
} }
public boolean isVerified() { 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.api.service.cdn_client.UploadsClient;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
import nl.andrewlalis.gymboard_api.domains.auth.model.User; 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 nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -50,6 +51,8 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
@Override @Override
public void generate() throws Exception { 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(); var uploads = generateUploads();
// Now that uploads are complete, we can proceed with generating the submissions. // Now that uploads are complete, we can proceed with generating the submissions.
@ -76,8 +79,6 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
submissions.add(submission); submissions.add(submission);
} }
submissionRepository.saveAll(submissions); submissionRepository.saveAll(submissions);
// After adding all the submissions, we'll signal to CDN that it can start processing.
} }
private Submission generateRandomSubmission( private Submission generateRandomSubmission(
@ -97,20 +98,23 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
weightUnit = WeightUnit.POUNDS; weightUnit = WeightUnit.POUNDS;
rawWeight = metricWeight.multiply(new BigDecimal("2.2046226218")); 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( var submission = new Submission(
ulid.nextULID(), ulid.nextULID(),
randomChoice(gyms, random), randomChoice(gyms, random),
randomChoice(exercises, random),
randomChoice(users, random), randomChoice(users, random),
time,
randomChoice(new ArrayList<>(uploads.keySet()), random), randomChoice(new ArrayList<>(uploads.keySet()), random),
rawWeight, properties
weightUnit,
metricWeight,
random.nextInt(13) + 1
); );
submission.setVerified(true); submission.setVerified(true);
submission.setProcessing(false);
var uploadData = uploads.get(submission.getVideoProcessingTaskId()); var uploadData = uploads.get(submission.getVideoProcessingTaskId());
submission.setVideoFileId(uploadData.getFirst()); submission.setVideoFileId(uploadData.getFirst());
submission.setThumbnailFileId(uploadData.getSecond()); submission.setThumbnailFileId(uploadData.getSecond());