// Package cache wraps Redis with the small surface the gateway needs. // // Every cached value is scoped to an Emby user id, because "what's on the home screen" // is per-user. Mutations (favourite, watched, playback stopped) drop that user's keys // so the next request re-reads Emby rather than serving a stale row. package cache import ( "context" "errors" "fmt" "strings" "time" "github.com/redis/go-redis/v9" "github.com/ponzischeme89/memby/server/internal/timing" ) // ErrMiss means the key was absent — an ordinary outcome, not a failure. var ErrMiss = errors.New("cache: miss") type Cache struct { rdb *redis.Client } func Open(redisURL string) (*Cache, error) { opts, err := redis.ParseURL(redisURL) if err != nil { return nil, fmt.Errorf("cache: parse url: %w", err) } return &Cache{rdb: redis.NewClient(opts)}, nil } func (c *Cache) Close() error { return c.rdb.Close() } func (c *Cache) Ping(ctx context.Context) error { return c.rdb.Ping(ctx).Err() } // Get reports its own outcome to the request's trace. Whether an answer came from // Redis is the first thing anybody asks of a slow screen, and it is the one fact the // duration alone can never carry: a 9-second home request that missed and a 9-second // home request that hit are two entirely different investigations. func (c *Cache) Get(ctx context.Context, key string) ([]byte, error) { defer timing.Start(ctx, timing.StageRedis)() b, err := c.rdb.Get(ctx, key).Bytes() if errors.Is(err, redis.Nil) { timing.Count(ctx, "miss") return nil, ErrMiss } if err != nil { timing.Count(ctx, "cache_error") return nil, err } timing.Count(ctx, "hit") return b, nil } // Set writes a value and, for a user-scoped key, records it in that user's index. // // The index is what makes invalidation proportional to one viewer's cached views rather // than to the whole keyspace — see InvalidateUser. It is written in the same pipeline as // the value, so it costs one round trip rather than two, and it is given a generous TTL // of its own so a user who stops watching cannot leave a set name growing for ever. func (c *Cache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error { defer timing.Start(ctx, timing.StageRedis)() index := userIndexFor(key) if index == "" { return c.rdb.Set(ctx, key, value, ttl).Err() } _, err := c.rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, key, value, ttl) pipe.SAdd(ctx, index, key) pipe.Expire(ctx, index, userIndexTTL) return nil }) return err } // userIndexTTL outlives any cached view by a wide margin. It is a bound on the index's // own lifetime, not a cache policy: the members it names may expire underneath it, which // costs a deletion of keys that are already gone and nothing else. const userIndexTTL = 30 * 24 * time.Hour // userIndexFor names the set that tracks one viewer's cached views, or empty for a key // that is not user-scoped. It parses rather than being told, so a caller cannot write a // user key and forget to index it — which would be an invalidation that silently misses. func userIndexFor(key string) string { if !strings.HasPrefix(key, "u:") { return "" } rest := key[len("u:"):] end := strings.IndexByte(rest, ':') if end <= 0 { return "" } return "idx:u:" + rest[:end] } func (c *Cache) Delete(ctx context.Context, keys ...string) error { if len(keys) == 0 { return nil } defer timing.Start(ctx, timing.StageRedis)() return c.rdb.Del(ctx, keys...).Err() } // InvalidateUser drops every cached view belonging to one Emby user. // // It reads the viewer's own index and deletes what it names: two round trips, whatever // the size of the keyspace. It used to SCAN for `u::*`, and SCAN's cost is a // property of the *whole* keyspace rather than of the pattern — Redis walks every key it // holds and filters, in pages, so a household with a few thousand cached item lookups // paid a few dozen round trips to find the handful of rows it wanted gone. That cost // landed on the playback stop report, which is a request the television makes as // somebody presses Back out of a film. // // The SCAN survives as the fallback for a user with no index, which is the honest answer // for two cases: a gateway upgraded while keys written by the previous build were still // live, and the window between an invalidation and the next write. Both are rare and // neither is any slower than the behaviour this replaced. func (c *Cache) InvalidateUser(ctx context.Context, userID string) error { defer timing.Start(ctx, timing.StageRedis)() index := "idx:u:" + userID keys, err := c.rdb.SMembers(ctx, index).Result() if err != nil { return err } if len(keys) == 0 { return c.scanInvalidate(ctx, userID) } return c.rdb.Del(ctx, append(keys, index)...).Err() } func (c *Cache) scanInvalidate(ctx context.Context, userID string) error { pattern := fmt.Sprintf("u:%s:*", userID) var cursor uint64 for { keys, next, err := c.rdb.Scan(ctx, cursor, pattern, 1000).Result() if err != nil { return err } if len(keys) > 0 { if err := c.rdb.Del(ctx, keys...).Err(); err != nil { return err } } if next == 0 { return nil } cursor = next } } // UserKey builds the namespaced key used by everything user-scoped. func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID, view) } // RecommendationsKey sits in its own `r:` namespace on purpose. // // Recommendations cost several Emby queries to build, so they survive ordinary user-view // invalidation and expire on their own slow-moving daily cadence. func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v3", userID) } // MagicPoolKey sits outside the `u:` namespace for the same reason RecommendationsKey // does, and one more besides: a Magic press *is* a playback change, so a pool filed under // the user's ordinary views would be invalidated by the very press that read it and every // press would pay the full rebuild. func MagicPoolKey(userID string) string { return fmt.Sprintf("m:%s:pool:v1", userID) } // MetadataKey is for catalogue facts that belong to no one: a person's biography, the // list of extras on a film, whether an episode ends a season. // // It sits outside the `u:` namespace for the reason RecommendationsKey does, and one // more besides. Every playback stop and every favourite calls InvalidateUser, which // drops `u::*` wholesale — so a person's biography, which cannot change when // somebody finishes an episode, was being thrown away several times an evening and // re-read from Emby by the next cast card anybody looked at. It is also unkeyed by // viewer, which is the larger win in a household: one lookup answers for everybody // rather than one per person. // // The rule for putting something here is narrow and worth stating: the value must // contain nothing derived from a viewer. Anything carrying UserData — watched marks, // resume positions, favourites — belongs in UserKey, where invalidation can reach it. func MetadataKey(view string) string { return "meta:" + view } // SessionKey caches a token→session lookup, keyed by token hash (never the token). func SessionKey(tokenHashHex string) string { return "sess:" + tokenHashHex }