FEAT: Added Redis Caching
Deploy Infrastructure / pulumi-up (push) Successful in 3s
Build, Push and Deploy Discovery / build-push-deploy (push) Failing after 34m37s

This commit is contained in:
FahdShalhoub
2026-08-29 23:12:11 +03:00
parent f66f3b555d
commit 35d4d6756e
8 changed files with 386 additions and 8 deletions
+57 -5
View File
@@ -23,10 +23,12 @@ catalogue search — see below) and the Swagger UI. Migration `0001_init` still
creates nothing (it predates the domain and exists only because
`internal/db/migrate.go` embeds `migrations/*.sql`, which will not compile
against an empty directory); `0002_create_videos_table` is where the schema
actually starts, and `0003``0005` add what the search needs. It now has an `internal/services` (SQS only) and an
actually starts, and `0003``0005` add what the search needs. It now has an `internal/services` (SQS and Redis) and an
`internal/consumers`, and a task role scoped to that one queue — receive,
delete, get-attributes, and nothing else. It cannot publish back onto the
topic: it is a subscriber, not a participant.
topic: it is a subscriber, not a participant. The Redis client needs nothing
from that role: ElastiCache is reached over the Redis protocol on the private
network, so the security group is what grants access, not IAM.
## Commands
@@ -49,7 +51,7 @@ never had a v1.
```bash
cd discovery
go run . # serves on :8080 (requires DB_* plus AWS_REGION/CATALOGUE_EVENTS_QUEUE_URL — no S3/MediaConvert)
go run . # serves on :8080 (requires DB_* plus AWS_REGION/CATALOGUE_EVENTS_QUEUE_URL/REDIS_ADDR — no S3/MediaConvert)
go run . migrate # applies pending DB migrations, then exits (no HTTP server)
go build ./...
go vet ./...
@@ -58,7 +60,8 @@ go vet ./...
Its OpenAPI spec is generated into `discovery/docs` by the same `swag init`
invocation as `cms`, run from `discovery/`. In `docker-compose.yml` it is a
service of its own on `127.0.0.1:8080`, wired to the `discovery` database and
role LocalStack provisions, plus the AWS_* vars its consumer needs and, like
role LocalStack provisions, plus the AWS_* vars its consumer needs and
`REDIS_ADDR` pointing at the `redis` container — and, like
`cms`, started in server mode only, so a freshly created local database needs
`docker exec discovery ./discovery migrate` once. Until that runs, the consumer
logs `relation "videos" does not exist` per announcement and leaves them on the
@@ -124,6 +127,41 @@ browse path (no title term) is a true index seek — that is what
`videos_recent_idx` is for, and it takes a 200k-row browse from ~15 ms to
~0.03 ms.
#### Search cache (Redis / ElastiCache)
Because of that caveat, the search reads through a Redis cache before it
touches Postgres — `handlers.cachedSearch`/`cacheSearch` around the repository
call, storing the `api.SearchResults` a page serialises to. `REDIS_ADDR` points
at the ElastiCache node `infrastructure/main.go` provisions (a `redis`
container under compose). Locally it takes a repeated ranked search from
~6.8 ms to ~0.5 ms.
- **The key is `discovery:search:v1:` + a canonical JSON encoding of
`{t: title, c: categories, l: limit, p: cursor}`.** The cursor *is* the page
identity — keyset paging has no page number to key on. `limit` is in the key
because the same title/categories/cursor at a different limit is a different
set of rows. JSON rather than pasted-together separators so a category name
containing `,` or `|` cannot be mistaken for a boundary. The title is
lowercased (`tsQueryFor` folds case anyway) but the **categories are not**:
`categories && $2` compares them verbatim, so `News` and `news` really are
different searches. Categories are sorted and deduped — `&&` means "any of
these", so order never changed the answer — and never nil, so `"categories":
[]` and an omitted member share an entry.
- **TTL is 60 s and nothing invalidates on write.** The consumer writes rows
continuously and would have to know which cached pages a new title belongs
on — for a ranked search, every page it outranks. Expiry is cheaper and
bounds staleness to roughly the announcement's own delivery lag.
- **Only 200s are cached**, so a malformed cursor still reaches the repository
and is still a 400.
- **Every cache failure is a miss.** A Get/Set error is logged and the search
is answered from Postgres; the service also *starts* with an unreachable
cache, warning rather than panicking as the DB and SQS assertions do — a slow
read side beats no read side. Each call is bounded by
`services.cacheOperationTimeout` (100 ms, retries off), because an unbounded
cache miss on a dead node costs the dial timeout *plus* the query it was
avoiding. A total cache outage adds ~200 ms per search (a failed read and a
failed write), not seconds.
`cms`, `discovery` and `infrastructure` hold no test files of their own. The
only tests in the repo are the black-box BDD scenarios in `tests/` — see below.
@@ -353,6 +391,18 @@ on boot if any required var is empty.
backups — intentionally minimal). Each app (`cms`, `discovery`) gets its
own login role and same-named database via the `postgresql` provider
(`newServiceDatabase`), so services never share DB credentials.
- **ElastiCache**: one `cache.t4g.micro` Redis node (`search-cache`, engine
7.1, single-AZ, no replica, no snapshots) that `discovery` answers repeated
catalogue searches from. Its contents are derivable from Postgres by
definition, so there is nothing to back up. Its security group admits 6379
from `ecs-service-sg` **only** — narrower than the database's, which also
admits the deployer's IP for the `postgresql` provider; there is nothing to
administer here from a laptop. The endpoint is the single node's address
(`CacheNodes[0]`, not `ConfigurationEndpoint` — that is a Memcached thing),
exported as `searchCacheAddress` and injected as `REDIS_ADDR`. Skipped under
LocalStack, where docker-compose runs a plain `redis:7-alpine` container
instead: ElastiCache picks its own endpoint, and compose needs a literal
`REDIS_ADDR` before anything is provisioned.
- **ECS Fargate**: one cluster (`app-cluster`), one ALB *per service* (each
gets its own DNS name rather than sharing a load balancer on different
ports). `deployFargateService(...)` is the shared helper building a
@@ -402,7 +452,9 @@ on boot if any required var is empty.
- `discovery-task-role` — the `discovery` container's own AWS identity, and
its only one: receive/delete/get-attributes on
`discovery-catalogue-events`. No S3, no MediaConvert, and no `sns:Publish`
— it consumes the catalogue, it does not add to it.
— it consumes the catalogue, it does not add to it. The search cache is
absent from it on purpose: ElastiCache is reached over the Redis protocol
inside the VPC, so `search-cache-sg` is the grant, not IAM.
- `mediaconvert-service-role` — trusted by `mediaconvert.amazonaws.com`,
not by ECS; the role MediaConvert itself assumes (passed as
`CreateJobInput.Role`) to read `raw-uploads-bucket` and write