// 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" "log" "strings" "thamanyah/discovery/internal/db/repositories" "thamanyah/discovery/internal/models" "thamanyah/discovery/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 // announcement is what cms publishes to the catalogue topic when a video // becomes ready. It is cms's published contract, mirrored here rather than // imported: the two services share no code, and this struct is exactly the // coupling between them — so a field renamed on one side has to be renamed // here too, deliberately. // // The subscription delivers raw, so this is the whole message body; there is // no SNS envelope to unwrap. type announcement struct { VideoID string `json:"videoId"` Title string `json:"title"` PlaybackURL string `json:"playbackUrl"` Categories []string `json:"categories"` } // CatalogueEvents consumes the announcements cms publishes. type CatalogueEvents struct{} // Run consumes announcements until ctx is cancelled. It is meant to be run in // its own goroutine for the lifetime of the process. func (c CatalogueEvents) Run(ctx context.Context) { log.Println("catalogue announcements consumer started") for { if ctx.Err() != nil { log.Println("catalogue announcements consumer stopped") return } messages, err := services.SQSClient.ReceiveMessages(ctx) if err != nil { if ctx.Err() != nil { log.Println("catalogue announcements consumer stopped") return } log.Printf("Something Went Wrong Receiving Announcements: %s", err) time.Sleep(receiveBackoff) continue } for _, message := range messages { if !c.handleAnnouncement(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 An Announcement: %s", err) } } } } // handleAnnouncement 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 announcement naming no video) is finished // with, however little it accomplished: leaving it on the queue would only // stall the announcements behind it. func (c CatalogueEvents) handleAnnouncement(ctx context.Context, body string) bool { var announced announcement if err := json.Unmarshal([]byte(body), &announced); err != nil { log.Printf("Discarding An Announcement That Is Not JSON: %s", err) return true } if strings.TrimSpace(announced.VideoID) == "" { log.Printf("Discarding An Announcement That Names No Video") return true } if err := repositories.VideoRepo.SaveVideo(ctx, models.Video{ ID: announced.VideoID, Title: announced.Title, PlaybackURL: announced.PlaybackURL, Categories: announced.Categories, }); err != nil { // Worth another attempt: the save is an upsert, so a redelivery // re-runs it harmlessly. log.Printf("Something Went Wrong Saving An Announced Video: video=%q: %s", announced.VideoID, err) return false } log.Printf("Catalogued An Announced Video: video=%q title=%q", announced.VideoID, announced.Title) return true }