FEAT: Added Redis Caching
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user