FEAT: Discovery Video Service Subscription
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m35s
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 1m55s
Deploy Infrastructure / pulumi-up (push) Successful in 2s

This commit is contained in:
FahdShalhoub
2026-08-29 16:50:58 +03:00
parent 98950aba20
commit 6f9e04ee97
34 changed files with 1754 additions and 65 deletions
+89
View File
@@ -15,6 +15,47 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/videos/{id}": {
"get": {
"description": "Returns the catalogue's copy of a video: what a reader needs to show and play it. A video appears here only after the CMS has announced it as ready, so a video that is still transcoding — or one the CMS never made ready — is a 404.",
"produces": [
"application/json"
],
"tags": [
"videos"
],
"summary": "Get a catalogued video",
"parameters": [
{
"type": "string",
"description": "Video id, as issued by the CMS",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/api.Video"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
}
}
}
},
"/health": {
"get": {
"description": "Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database connection.",
@@ -45,6 +86,54 @@ const docTemplate = `{
"example": "ok"
}
}
},
"api.ProblemDetails": {
"type": "object",
"properties": {
"detail": {
"type": "string",
"example": "The catalogue could not be read from the database. This is a server-side fault and the request was not processed; retrying in a few moments may succeed."
},
"status": {
"type": "integer",
"example": 500
},
"title": {
"type": "string",
"example": "Something Went Wrong Loading The Catalogue"
},
"type": {
"type": "string",
"example": "about:blank"
}
}
},
"api.Video": {
"type": "object",
"properties": {
"categories": {
"type": "array",
"items": {
"type": "string"
},
"example": [
"documentary",
"news"
]
},
"id": {
"type": "string",
"example": "0f8fad5b-d9cb-469f-a165-70867728950e"
},
"playbackUrl": {
"type": "string",
"example": "https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"
},
"title": {
"type": "string",
"example": "The Evening Bulletin"
}
}
}
}
}`
+89
View File
@@ -8,6 +8,47 @@
},
"basePath": "/",
"paths": {
"/api/videos/{id}": {
"get": {
"description": "Returns the catalogue's copy of a video: what a reader needs to show and play it. A video appears here only after the CMS has announced it as ready, so a video that is still transcoding — or one the CMS never made ready — is a 404.",
"produces": [
"application/json"
],
"tags": [
"videos"
],
"summary": "Get a catalogued video",
"parameters": [
{
"type": "string",
"description": "Video id, as issued by the CMS",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/api.Video"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
}
}
}
},
"/health": {
"get": {
"description": "Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database connection.",
@@ -38,6 +79,54 @@
"example": "ok"
}
}
},
"api.ProblemDetails": {
"type": "object",
"properties": {
"detail": {
"type": "string",
"example": "The catalogue could not be read from the database. This is a server-side fault and the request was not processed; retrying in a few moments may succeed."
},
"status": {
"type": "integer",
"example": 500
},
"title": {
"type": "string",
"example": "Something Went Wrong Loading The Catalogue"
},
"type": {
"type": "string",
"example": "about:blank"
}
}
},
"api.Video": {
"type": "object",
"properties": {
"categories": {
"type": "array",
"items": {
"type": "string"
},
"example": [
"documentary",
"news"
]
},
"id": {
"type": "string",
"example": "0f8fad5b-d9cb-469f-a165-70867728950e"
},
"playbackUrl": {
"type": "string",
"example": "https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"
},
"title": {
"type": "string",
"example": "The Evening Bulletin"
}
}
}
}
}
+65
View File
@@ -6,6 +6,41 @@ definitions:
example: ok
type: string
type: object
api.ProblemDetails:
properties:
detail:
example: The catalogue could not be read from the database. This is a server-side
fault and the request was not processed; retrying in a few moments may succeed.
type: string
status:
example: 500
type: integer
title:
example: Something Went Wrong Loading The Catalogue
type: string
type:
example: about:blank
type: string
type: object
api.Video:
properties:
categories:
example:
- documentary
- news
items:
type: string
type: array
id:
example: 0f8fad5b-d9cb-469f-a165-70867728950e
type: string
playbackUrl:
example: https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8
type: string
title:
example: The Evening Bulletin
type: string
type: object
info:
contact: {}
description: JSON API for browsing the Thamanyah catalogue. Read-side counterpart
@@ -13,6 +48,36 @@ info:
title: Thamanyah Discovery API
version: "1.0"
paths:
/api/videos/{id}:
get:
description: 'Returns the catalogue''s copy of a video: what a reader needs
to show and play it. A video appears here only after the CMS has announced
it as ready, so a video that is still transcoding — or one the CMS never made
ready — is a 404.'
parameters:
- description: Video id, as issued by the CMS
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/api.Video'
"404":
description: Not Found
schema:
$ref: '#/definitions/api.ProblemDetails'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/api.ProblemDetails'
summary: Get a catalogued video
tags:
- videos
/health:
get:
description: Reports that the service is up and serving. Used as the ALB target
+15
View File
@@ -11,6 +11,21 @@ require (
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.45.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.33.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.6 // indirect
+30
View File
@@ -4,6 +4,36 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1 h1:jXP3BdVenFa8RfLVH+D2gswrWZHJcgtygKCf22APFqo=
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1/go.mod h1:d4DToDhLnEofHKvFu4yCF0Be65pZW267COfKOztsZOQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+13
View File
@@ -0,0 +1,13 @@
package api
// Video is the catalogue's copy of a video, as GET /api/videos/{id} serves it.
// It is deliberately not cms's video record of the same name: the catalogue
// publishes what a reader needs to show and play a video, and none of the
// ingestion detail — no storage key, no transcoding job id, no status, since
// a video that is not ready never reaches the catalogue at all.
type Video struct {
ID string `json:"id" example:"0f8fad5b-d9cb-469f-a165-70867728950e"`
Title string `json:"title" example:"The Evening Bulletin"`
PlaybackURL string `json:"playbackUrl" example:"https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"`
Categories []string `json:"categories" example:"documentary,news"`
}
+110
View File
@@ -0,0 +1,110 @@
// 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
}
@@ -0,0 +1 @@
DROP TABLE videos;
@@ -0,0 +1,19 @@
-- The catalogue's own copy of a video, built from the announcements cms
-- publishes on the catalogue topic. It is a read model, not a second source of
-- truth: every column here arrives in an announcement, and nothing in this
-- service ever writes a video of its own.
--
-- id is the id cms issued, carried in the announcement and reused verbatim, so
-- the two services can talk about the same video without a mapping table. It
-- is therefore NOT generated here: no DEFAULT, unlike cms's videos.id.
CREATE TABLE videos (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
playback_url TEXT NOT NULL DEFAULT '',
-- Denormalized on purpose. cms owns the category vocabulary and sends
-- names, so there is nothing here to join to and no id to keep in step;
-- an array is what the read side actually serves.
categories TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,9 @@
package repositories
import "errors"
// Sentinel errors the repositories return so handlers can tell "there is no
// such row" from "the query failed", without importing database/sql to do it.
// ErrVideoNotFound reports that the catalogue holds no video with that id.
var ErrVideoNotFound = errors.New("video not found")
@@ -0,0 +1,59 @@
package repositories
import (
"context"
"database/sql"
"errors"
"thamanyah/discovery/internal/models"
"github.com/lib/pq"
)
type VideoRepository struct {
SQLDB *sql.DB
}
var VideoRepo VideoRepository
// SaveVideo records an announced video, replacing what the catalogue already
// held for that id.
//
// The upsert is what makes the consumer safe to retry: the catalogue topic
// delivers at-least-once, so the same announcement can arrive twice, and a
// plain INSERT would fail the second time on the primary key. Re-announcing a
// video is also how it is corrected — a later announcement wins, which is why
// every column is overwritten rather than merged.
func (svc VideoRepository) SaveVideo(ctx context.Context, v models.Video) error {
_, err := svc.SQLDB.ExecContext(ctx, `
INSERT INTO videos (id, title, playback_url, categories)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET title = EXCLUDED.title,
playback_url = EXCLUDED.playback_url,
categories = EXCLUDED.categories,
updated_at = now()
`, v.ID, v.Title, v.PlaybackURL, pq.Array(v.Categories))
return err
}
// GetVideoByID reads the catalogue's copy of one video. It returns
// ErrVideoNotFound when the catalogue has not heard about it — which, for a
// video cms has only just made ready, is a matter of timing rather than a
// mistake.
func (svc VideoRepository) GetVideoByID(ctx context.Context, id string) (models.Video, error) {
var v models.Video
err := svc.SQLDB.QueryRowContext(ctx, `
SELECT id, title, playback_url, categories, created_at, updated_at
FROM videos
WHERE id = $1
`, id).Scan(&v.ID, &v.Title, &v.PlaybackURL, pq.Array(&v.Categories), &v.CreatedAt, &v.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return models.Video{}, ErrVideoNotFound
}
if err != nil {
return models.Video{}, err
}
return v, nil
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
import (
"errors"
"log"
"net/http"
"thamanyah/discovery/internal/api"
"thamanyah/discovery/internal/db/repositories"
)
// GetVideo serves the catalogue's copy of one video.
//
// @Summary Get a catalogued video
// @Description Returns the catalogue's copy of a video: what a reader needs to show and play it. A video appears here only after the CMS has announced it as ready, so a video that is still transcoding — or one the CMS never made ready — is a 404.
// @Tags videos
// @Produce json
// @Param id path string true "Video id, as issued by the CMS"
// @Success 200 {object} api.Video
// @Failure 404 {object} api.ProblemDetails
// @Failure 500 {object} api.ProblemDetails
// @Router /api/videos/{id} [get]
func GetVideo(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
video, err := repositories.VideoRepo.GetVideoByID(r.Context(), id)
if errors.Is(err, repositories.ErrVideoNotFound) {
writeProblem(w, http.StatusNotFound, "Video Not In The Catalogue",
"No video with that id is in the catalogue. A video appears here once the CMS announces it as ready, so one that is still being transcoded is not here yet.")
return
}
if err != nil {
log.Printf("Something Went Wrong Loading A Catalogued Video: id=%q: %s", id, err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading The Catalogue",
"The video could not be read from the database. This is a server-side fault and the request was not processed; retrying in a few moments may succeed.")
return
}
// Translated field by field rather than returned as-is: models.Video is the
// row, api.Video is the published schema, and neither should drag the other
// along when it changes.
writeJSON(w, http.StatusOK, api.Video{
ID: video.ID,
Title: video.Title,
PlaybackURL: video.PlaybackURL,
// Never null on the wire: a video with no categories has an empty
// list, which is a different thing from "unknown".
Categories: append([]string{}, video.Categories...),
})
}
+18
View File
@@ -0,0 +1,18 @@
package models
import "time"
// Video is the catalogue's copy of a video cms has announced as ready. Every
// member is filled from the announcement — discovery originates none of it.
type Video struct {
// ID is the id cms issued, reused verbatim so both services name the same
// video the same way.
ID string
Title string
PlaybackURL string
// Categories are names, not ids: cms owns the vocabulary and announces it
// by name, so nothing here has to track its numbering.
Categories []string
CreatedAt time.Time
UpdatedAt time.Time
}
+83
View File
@@ -0,0 +1,83 @@
// Package services holds this service's AWS clients. Discovery reaches exactly
// one AWS API — the SQS queue subscribed to the catalogue topic — so unlike
// cms there is no S3 or MediaConvert here, and nothing database-related.
package services
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
var SQSClient SQS
// QueueMessage is one message taken off the queue. ReceiptHandle is what
// identifies it for deletion — it belongs to this delivery, not to the
// message, so it cannot be held across receives.
type QueueMessage struct {
Body string
ReceiptHandle string
}
type SQS interface {
ReceiveMessages(ctx context.Context) ([]QueueMessage, error)
DeleteMessage(ctx context.Context, receiptHandle string) error
}
type SQSConcrete struct {
SQSClient *sqs.Client
QueueURL string
}
// receiveWaitSeconds turns every receive into a long poll: the call parks on
// the server until a message arrives or this elapses, so an event is picked up
// within milliseconds of being published while an idle consumer costs one
// request every twenty seconds rather than spinning. 20 is the AWS maximum.
const receiveWaitSeconds = 20
func (svc SQSConcrete) ReceiveMessages(ctx context.Context) ([]QueueMessage, error) {
output, err := svc.SQSClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: &svc.QueueURL,
MaxNumberOfMessages: 10,
WaitTimeSeconds: receiveWaitSeconds,
})
if err != nil {
return nil, err
}
messages := make([]QueueMessage, 0, len(output.Messages))
for _, message := range output.Messages {
if message.Body == nil || message.ReceiptHandle == nil {
continue
}
messages = append(messages, QueueMessage{
Body: *message.Body,
ReceiptHandle: *message.ReceiptHandle,
})
}
return messages, nil
}
// DeleteMessage acknowledges a message. Until this is called the message is
// merely invisible, and it returns to the queue when its visibility timeout
// expires — which is how a handler that failed gets another attempt.
func (svc SQSConcrete) DeleteMessage(ctx context.Context, receiptHandle string) error {
_, err := svc.SQSClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: &svc.QueueURL,
ReceiptHandle: &receiptHandle,
})
return err
}
// AssertSuccessfulConnection verifies the queue is reachable and readable with
// the configured credentials. It panics on failure, since a service whose
// consumer cannot start would serve a catalogue that silently stops growing.
func (svc SQSConcrete) AssertSuccessfulConnection(ctx context.Context) {
if _, err := svc.SQSClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
QueueUrl: &svc.QueueURL,
}); err != nil {
panic(fmt.Errorf("sqs: cannot connect to queue %q: %w", svc.QueueURL, err))
}
}
+36
View File
@@ -6,9 +6,14 @@ import (
"log"
"net/http"
"os"
"thamanyah/discovery/internal/consumers"
"thamanyah/discovery/internal/db"
"thamanyah/discovery/internal/db/repositories"
"thamanyah/discovery/internal/handlers"
"thamanyah/discovery/internal/services"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sqs"
_ "github.com/lib/pq"
httpSwagger "github.com/swaggo/http-swagger/v2"
@@ -38,13 +43,44 @@ func runMigrate() {
}
func runServer() {
// The queue subscribed to cms's catalogue topic. This is the only AWS
// service discovery talks to.
catalogueQueueURL := os.Getenv("CATALOGUE_EVENTS_QUEUE_URL")
if catalogueQueueURL == "" {
panic(fmt.Errorf("missing required env var: CATALOGUE_EVENTS_QUEUE_URL"))
}
awsConfig, err := awsconfig.LoadDefaultConfig(context.Background())
if err != nil {
panic(err)
}
concreteSQSClient := &services.SQSConcrete{
SQSClient: sqs.NewFromConfig(awsConfig),
QueueURL: catalogueQueueURL,
}
concreteSQSClient.AssertSuccessfulConnection(context.Background())
services.SQSClient = concreteSQSClient
concreteDBClient := db.CreateDBConnection(requireDBConnectionString())
defer db.CloseConnection(concreteDBClient)
db.AssertSuccessfulConnection(context.Background(), concreteDBClient)
repositories.VideoRepo = repositories.VideoRepository{
SQLDB: concreteDBClient,
}
// Consumes announcements for the lifetime of the process. Cancelled when
// runServer returns, which today only happens if the server itself fails.
consumerCtx, stopConsumer := context.WithCancel(context.Background())
defer stopConsumer()
go consumers.CatalogueEvents{}.Run(consumerCtx)
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handlers.Health)
mux.HandleFunc("GET /api/videos/{id}", handlers.GetVideo)
// Swagger UI and the generated spec. The UI assets are embedded in the
// binary by swaggo/files, so this needs no static directory on disk.