// 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 }