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