231 lines
8.3 KiB
Go
231 lines
8.3 KiB
Go
// 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"
|
|
"strings"
|
|
"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"`
|
|
// Present on COMPLETE only, and only for output groups that produced
|
|
// something. It is how the service learns where the output landed
|
|
// rather than having to guess the path back from the job settings.
|
|
OutputGroupDetails []outputGroup `json:"outputGroupDetails"`
|
|
} `json:"detail"`
|
|
}
|
|
|
|
// outputGroup is one output group's result. An HLS group lists the manifests
|
|
// it wrote under playlistFilePaths, master first; the segments are not listed.
|
|
type outputGroup struct {
|
|
Type string `json:"type"`
|
|
PlaylistFilePaths []string `json:"playlistFilePaths"`
|
|
}
|
|
|
|
// MediaConvertEvents consumes MediaConvert job state changes.
|
|
type MediaConvertEvents struct {
|
|
// PlaybackBaseURL is the public host the encoded output is served from —
|
|
// the CloudFront distribution in front of the output bucket. MediaConvert
|
|
// reports s3:// paths into a bucket that blocks all public access, so a
|
|
// path is only usable to a player once it has been rewritten onto this.
|
|
PlaybackBaseURL string
|
|
}
|
|
|
|
// hlsPlaylist picks the master playlist out of a finished job's output groups.
|
|
// It returns "" when the job produced no HLS group, which is not an error: a
|
|
// job configured with only a file group legitimately has no playlist.
|
|
func hlsPlaylist(groups []outputGroup) string {
|
|
for _, group := range groups {
|
|
for _, playlist := range group.PlaylistFilePaths {
|
|
if strings.HasSuffix(playlist, ".m3u8") {
|
|
return playlist
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// playbackURL rewrites an s3://bucket/key path onto the public delivery host.
|
|
// The bucket is dropped rather than checked: the job writes to exactly one
|
|
// output bucket, and that bucket is what PlaybackBaseURL fronts.
|
|
func (c MediaConvertEvents) playbackURL(s3URI string) (string, bool) {
|
|
const scheme = "s3://"
|
|
|
|
if !strings.HasPrefix(s3URI, scheme) {
|
|
return "", false
|
|
}
|
|
|
|
_, key, found := strings.Cut(strings.TrimPrefix(s3URI, scheme), "/")
|
|
if !found || key == "" {
|
|
return "", false
|
|
}
|
|
|
|
return strings.TrimRight(c.PlaybackBaseURL, "/") + "/" + key, true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Run 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 (c MediaConvertEvents) Run(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 !c.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 (c MediaConvertEvents) 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
|
|
}
|
|
|
|
// Only a finished job has output. A job that failed is recorded with no
|
|
// playback URL, which also clears one from an earlier attempt.
|
|
playbackURL := ""
|
|
if status == models.VideoStatusReady {
|
|
if playlist := hlsPlaylist(event.Detail.OutputGroupDetails); playlist != "" {
|
|
rewritten, ok := c.playbackURL(playlist)
|
|
if !ok {
|
|
log.Printf("Could Not Turn A Playlist Path Into A Playback URL: job=%q path=%q",
|
|
event.Detail.JobID, playlist)
|
|
}
|
|
playbackURL = rewritten
|
|
} else {
|
|
// Worth knowing about: the video is playable in principle but the
|
|
// catalogue has nothing to point a player at.
|
|
log.Printf("A Finished Job Reported No HLS Playlist: job=%q", event.Detail.JobID)
|
|
}
|
|
}
|
|
|
|
video, err := repositories.VideoRepo.UpdateVideoOutcomeByJobID(ctx, event.Detail.JobID, status, playbackURL)
|
|
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 playback=%q", event.Detail.JobID, status, playbackURL)
|
|
|
|
// Only a video that came out ready is worth showing, so only that one is
|
|
// announced. A failed job has already had its status recorded above, and
|
|
// the catalogue has no use for it.
|
|
if status != models.VideoStatusReady {
|
|
return true
|
|
}
|
|
|
|
if err := services.CatalogueClient.AnnounceVideo(ctx, services.CatalogueVideo{
|
|
VideoID: video.ID,
|
|
Title: video.Title,
|
|
PlaybackURL: video.PlaybackURL,
|
|
Categories: video.CategoryNames,
|
|
}); err != nil {
|
|
// Worth another attempt: the outcome is already recorded, and the
|
|
// update is idempotent, so a redelivery re-runs it and tries the
|
|
// announcement again. The cost is that a consumer can see the same
|
|
// video twice, which is what at-least-once delivery means anyway.
|
|
log.Printf("Something Went Wrong Announcing A Ready Video: video=%q job=%q: %s",
|
|
video.ID, event.Detail.JobID, err)
|
|
return false
|
|
}
|
|
|
|
log.Printf("Announced A Ready Video: video=%q playback=%q", video.ID, video.PlaybackURL)
|
|
return true
|
|
}
|