FEAT: Update Status Of Video Row On Media Convert Update
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// Package consumers holds the queue-driven half of the service: work that
|
||||
// arrives on a queue rather than as an HTTP request. It is to SQS what
|
||||
// internal/handlers is to HTTP — it decodes a message, calls repositories, and
|
||||
// decides what the message's fate is — so the same seams apply, and no SQL
|
||||
// lives here.
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"thamanyah/cms/v2/internal/db/repositories"
|
||||
"thamanyah/cms/v2/internal/models"
|
||||
"thamanyah/cms/v2/internal/services"
|
||||
"time"
|
||||
)
|
||||
|
||||
// receiveBackoff is how long the loop waits after a failed receive, so a queue
|
||||
// that is unreachable produces a slow trickle of log lines rather than a spin.
|
||||
const receiveBackoff = 5 * time.Second
|
||||
|
||||
// jobStateChange is the EventBridge event MediaConvert emits on every job
|
||||
// state transition. The subscription delivers it raw, so this is the whole
|
||||
// message body — there is no SNS envelope to unwrap. Only the members this
|
||||
// service acts on are modelled; AWS sends a good deal more.
|
||||
type jobStateChange struct {
|
||||
Source string `json:"source"`
|
||||
DetailType string `json:"detail-type"`
|
||||
Detail struct {
|
||||
JobID string `json:"jobId"`
|
||||
Status string `json:"status"`
|
||||
} `json:"detail"`
|
||||
}
|
||||
|
||||
// statusForJobState maps MediaConvert's job states onto the catalogue's. The
|
||||
// second return is false for states that are not an outcome — the job is still
|
||||
// running, and the record should stay where it is rather than being rewritten
|
||||
// with what it already says.
|
||||
func statusForJobState(state string) (models.VideoStatus, bool) {
|
||||
switch state {
|
||||
case "COMPLETE":
|
||||
return models.VideoStatusReady, true
|
||||
case "ERROR", "CANCELED":
|
||||
return models.VideoStatusFailed, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// RunMediaConvertEvents consumes job state changes until ctx is cancelled. It
|
||||
// is meant to be run in its own goroutine for the lifetime of the process.
|
||||
func RunMediaConvertEvents(ctx context.Context) {
|
||||
log.Println("mediaconvert job events consumer started")
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
log.Println("mediaconvert job events consumer stopped")
|
||||
return
|
||||
}
|
||||
|
||||
messages, err := services.SQSClient.ReceiveMessages(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
log.Println("mediaconvert job events consumer stopped")
|
||||
return
|
||||
}
|
||||
log.Printf("Something Went Wrong Receiving Job Events: %s", err)
|
||||
time.Sleep(receiveBackoff)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, message := range messages {
|
||||
if !handleJobEvent(ctx, message.Body) {
|
||||
// Left on the queue on purpose: it becomes visible again when
|
||||
// the visibility timeout expires, and reaches the dead-letter
|
||||
// queue if it keeps failing.
|
||||
continue
|
||||
}
|
||||
|
||||
if err := services.SQSClient.DeleteMessage(ctx, message.ReceiptHandle); err != nil {
|
||||
log.Printf("Something Went Wrong Acknowledging A Job Event: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleJobEvent processes one message and reports whether it is finished
|
||||
// with — that is, whether it should be deleted from the queue. It returns
|
||||
// false only when a retry could plausibly succeed. Anything a retry cannot fix
|
||||
// (a body that will never parse, an event for a job nobody has) is finished
|
||||
// with, however little it accomplished: leaving it on the queue would only
|
||||
// stall the events behind it.
|
||||
func handleJobEvent(ctx context.Context, body string) bool {
|
||||
var event jobStateChange
|
||||
if err := json.Unmarshal([]byte(body), &event); err != nil {
|
||||
log.Printf("Discarding A Job Event That Is Not JSON: %s", err)
|
||||
return true
|
||||
}
|
||||
|
||||
if event.Source != "aws.mediaconvert" || event.DetailType != "MediaConvert Job State Change" {
|
||||
log.Printf("Discarding An Event That Is Not A MediaConvert Job State Change: source=%q detail-type=%q",
|
||||
event.Source, event.DetailType)
|
||||
return true
|
||||
}
|
||||
|
||||
if event.Detail.JobID == "" {
|
||||
log.Printf("Discarding A Job Event That Names No Job")
|
||||
return true
|
||||
}
|
||||
|
||||
status, isOutcome := statusForJobState(event.Detail.Status)
|
||||
if !isOutcome {
|
||||
// SUBMITTED, PROGRESSING and STATUS_UPDATE say the job is still
|
||||
// running, which is what "processing" already records.
|
||||
return true
|
||||
}
|
||||
|
||||
err := repositories.VideoRepo.UpdateVideoStatusByJobID(ctx, event.Detail.JobID, status)
|
||||
if errors.Is(err, repositories.ErrVideoNotFound) {
|
||||
// Not a fault: the topic carries every job in the account, including
|
||||
// ones this service never submitted.
|
||||
log.Printf("Ignoring A Job Event For A Job No Video Has: job=%q status=%q",
|
||||
event.Detail.JobID, event.Detail.Status)
|
||||
return true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Something Went Wrong Recording A Job Outcome: job=%q status=%q: %s",
|
||||
event.Detail.JobID, event.Detail.Status, err)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("Recorded A Job Outcome: job=%q status=%q", event.Detail.JobID, status)
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user