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
+6 -2
View File
@@ -3,8 +3,11 @@ module thamanyah/discovery
go 1.25.12
require (
github.com/aws/aws-sdk-go-v2/config v1.33.1
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/lib/pq v1.12.3
github.com/redis/go-redis/v9 v9.22.0
github.com/swaggo/http-swagger/v2 v2.0.2
github.com/swaggo/swag v1.16.6
)
@@ -12,7 +15,6 @@ 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
@@ -21,11 +23,11 @@ require (
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/cespare/xxhash/v2 v2.3.0 // 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
@@ -33,8 +35,10 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
+14
View File
@@ -34,6 +34,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8s
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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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=
@@ -77,6 +83,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@@ -105,6 +113,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -116,6 +126,8 @@ github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSy
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
@@ -126,6 +138,8 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+126 -1
View File
@@ -1,12 +1,17 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"slices"
"strings"
"thamanyah/discovery/internal/api"
"thamanyah/discovery/internal/db/repositories"
"thamanyah/discovery/internal/services"
"time"
)
// GetVideo serves the catalogue's copy of one video.
@@ -64,8 +69,112 @@ const (
// cursor still reaches the rest — because a client that wants everything
// is doing something legitimate, just not in one request.
maxSearchPageSize = 100
// searchCacheTTL is how long a page of results stays servable from the
// cache before it has to be built again.
//
// Nothing invalidates an entry when a video is announced: the consumer
// writes rows continuously and would have to know which of the cached
// pages a new title belongs on, which for a ranked search is every page it
// outranks. Expiry is the cheaper answer, and it bounds the staleness — a
// video is findable within a minute of being catalogued, which is the same
// order as the announcement's own delivery lag.
searchCacheTTL = time.Minute
)
// searchCacheKey is what one page of search results is stored under: the title
// searched for, the categories it was narrowed to, and the page within that
// result set.
//
// The page is named by the cursor, because with keyset paging that is what a
// page *is* — there is no page number to key on, and the cursor identifies the
// same rows whenever it is presented. The empty cursor is the first page.
//
// The limit is in the key too, even though a reader would not call it part of
// "which page". It has to be: the same title, categories and cursor with a
// different limit is a different set of rows, and sharing an entry between
// them would hand a client asking for 50 a page of 20.
//
// The parts are JSON-encoded rather than pasted together with separators so
// that no category name can be mistaken for the boundary between two of them —
// {"c":["a,b"]} and {"c":["a","b"]} stay distinguishable, which "a,b" and
// "a,b" would not. The key stays readable in redis-cli either way.
func searchCacheKey(title string, categories []string, limit int, cursor string) string {
// The title is lowercased because the search itself is: tsQueryFor folds
// case before it builds the tsquery, so two spellings that differ only in
// case are the same search and should be the same entry. The categories
// are not — they are compared to the stored array verbatim, so "News" and
// "news" really do ask different questions.
//
// Sorting the categories is safe for the same reason the SQL uses &&:
// they mean "any of these", so their order never changed the answer. Two
// readers naming the same categories in a different order now share one
// entry instead of building the same page twice.
//
// Never nil, so that a client sending "categories": [] and one omitting
// the member entirely — the same search, since both mean "every category"
// — land on one entry rather than on `null` and `[]`.
sorted := append([]string{}, categories...)
slices.Sort(sorted)
sorted = slices.Compact(sorted)
parts := struct {
Title string `json:"t"`
Categories []string `json:"c"`
Limit int `json:"l"`
Cursor string `json:"p"`
}{
Title: strings.ToLower(strings.TrimSpace(title)),
Categories: sorted,
Limit: limit,
Cursor: cursor,
}
// Cannot fail: every field is a string, a string slice or an int.
encoded, _ := json.Marshal(parts)
return "discovery:search:v1:" + string(encoded)
}
// cachedSearch returns the page held under key, or false when there is none to
// serve.
//
// Every failure reads as "no cached page": a cache that is unreachable, slow
// or holding something unreadable must cost a reader nothing more than the
// database query they would have paid for anyway. Only faults worth acting on
// are logged — a miss is not one.
func cachedSearch(ctx context.Context, key string) (api.SearchResults, bool) {
encoded, err := services.CacheClient.Get(ctx, key)
if errors.Is(err, services.ErrCacheMiss) {
return api.SearchResults{}, false
}
if err != nil {
log.Printf("Could Not Read The Search Cache, Falling Back To The Database: key=%q: %s", key, err)
return api.SearchResults{}, false
}
var results api.SearchResults
if err := json.Unmarshal(encoded, &results); err != nil {
// Only reachable if something else wrote this key, or the shape
// changed without the v1 in the prefix changing with it.
log.Printf("Could Not Read A Cached Search Page, Falling Back To The Database: key=%q: %s", key, err)
return api.SearchResults{}, false
}
return results, true
}
func cacheSearch(ctx context.Context, key string, results api.SearchResults) {
encoded, err := json.Marshal(results)
if err != nil {
log.Printf("Could Not Encode A Search Page For The Cache: key=%q: %s", key, err)
return
}
if err := services.CacheClient.Set(ctx, key, encoded, searchCacheTTL); err != nil {
log.Printf("Could Not Write The Search Cache: key=%q: %s", key, err)
}
}
// SearchVideos serves the catalogue's lexical search.
//
// It is served on QUERY rather than GET or POST: a search is safe and
@@ -94,6 +203,19 @@ func SearchVideos(w http.ResponseWriter, r *http.Request) {
limit = maxSearchPageSize
}
// Read through the cache before touching Postgres. A ranked search is the
// expensive request this service serves — ts_rank is computed per matching
// row, so deep pages scan rather than seek — and readers ask for the same
// few things over and over, which is exactly the shape a cache pays for.
//
// Only successful pages are ever stored, so a malformed cursor still
// reaches the repository and is still rejected below.
cacheKey := searchCacheKey(request.Title, request.Categories, limit, request.Cursor)
if cached, hit := cachedSearch(r.Context(), cacheKey); hit {
writeJSON(w, http.StatusOK, cached)
return
}
found, next, err := repositories.VideoRepo.SearchVideos(r.Context(), request.Title, request.Categories, limit, request.Cursor)
if errors.Is(err, repositories.ErrInvalidCursor) {
writeProblem(w, http.StatusBadRequest, "Malformed Search Cursor",
@@ -119,5 +241,8 @@ func SearchVideos(w http.ResponseWriter, r *http.Request) {
})
}
writeJSON(w, http.StatusOK, api.SearchResults{Videos: videos, NextCursor: next})
results := api.SearchResults{Videos: videos, NextCursor: next}
cacheSearch(r.Context(), cacheKey, results)
writeJSON(w, http.StatusOK, results)
}
+58
View File
@@ -0,0 +1,58 @@
package services
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
var CacheClient Cache
var ErrCacheMiss = errors.New("cache: miss")
type Cache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
}
type RedisConcrete struct {
Redis *redis.Client
}
const cacheOperationTimeout = 100 * time.Millisecond
func (svc RedisConcrete) Get(ctx context.Context, key string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, cacheOperationTimeout)
defer cancel()
value, err := svc.Redis.Get(ctx, key).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrCacheMiss
}
if err != nil {
return nil, err
}
return value, nil
}
func (svc RedisConcrete) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, cacheOperationTimeout)
defer cancel()
return svc.Redis.Set(ctx, key, value, ttl).Err()
}
func (svc RedisConcrete) Close() error {
return svc.Redis.Close()
}
func (svc RedisConcrete) AssertSuccessfulConnection(ctx context.Context) {
if err := svc.Redis.Ping(ctx).Err(); err != nil {
log.Printf("WARNING: %s", fmt.Errorf("redis: cannot reach cache at %q, searches will be served from the database: %w",
svc.Redis.Options().Addr, err))
}
}
+34
View File
@@ -11,10 +11,12 @@ import (
"thamanyah/discovery/internal/db/repositories"
"thamanyah/discovery/internal/handlers"
"thamanyah/discovery/internal/services"
"time"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sqs"
_ "github.com/lib/pq"
"github.com/redis/go-redis/v9"
httpSwagger "github.com/swaggo/http-swagger/v2"
_ "thamanyah/discovery/docs"
@@ -65,6 +67,38 @@ func runServer() {
services.SQSClient = concreteSQSClient
// The ElastiCache Redis node the catalogue search is answered from. Not an
// AWS API call — the address resolves inside the VPC and the security
// group is what grants access — so it needs nothing from the task role and
// nothing from awsConfig.
//
// The address is required because a missing one is a deployment mistake,
// not a choice; whether the node actually answers is a different question,
// and one the service deliberately survives getting "no" to.
redisAddress := os.Getenv("REDIS_ADDR")
if redisAddress == "" {
panic(fmt.Errorf("missing required env var: REDIS_ADDR"))
}
concreteCacheClient := &services.RedisConcrete{
Redis: redis.NewClient(&redis.Options{
Addr: redisAddress,
// Per-call deadlines on top of the budget RedisConcrete already
// applies, so a connection that hangs rather than refusing cannot
// tie up a goroutine past the request that opened it. Retries are
// off for the same reason the budget is small: a second attempt at
// a cache costs more than the query it is saving.
DialTimeout: 100 * time.Millisecond,
ReadTimeout: 100 * time.Millisecond,
WriteTimeout: 100 * time.Millisecond,
MaxRetries: -1,
}),
}
defer func() { _ = concreteCacheClient.Close() }()
concreteCacheClient.AssertSuccessfulConnection(context.Background())
services.CacheClient = concreteCacheClient
concreteDBClient := db.CreateDBConnection(requireDBConnectionString())
defer db.CloseConnection(concreteDBClient)
db.AssertSuccessfulConnection(context.Background(), concreteDBClient)