From d021a8eaddc329bd589600dea35eb85c99da2ec2 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 10 May 2026 18:04:29 +0200 Subject: [PATCH] feat: bound in-memory caches with LRU eviction --- api/playback/proxy_security.go | 17 ++---- api/playback/proxy_security_test.go | 5 +- api/playback/service_base.go | 93 ++++++++++++----------------- go.mod | 2 + go.sum | 2 + internal/server/routes.go | 6 +- 6 files changed, 56 insertions(+), 69 deletions(-) diff --git a/api/playback/proxy_security.go b/api/playback/proxy_security.go index 78cd4f4..ff91535 100644 --- a/api/playback/proxy_security.go +++ b/api/playback/proxy_security.go @@ -20,7 +20,6 @@ const ( proxySegmentTokenTTL = 6 * time.Hour proxySubtitleTokenTTL = 6 * time.Hour ) -const proxyHostCheckTTL = 2 * time.Minute type proxyScope string @@ -262,11 +261,8 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er return nil } - now := time.Now() - s.proxyHostMu.RLock() - cached, ok := s.proxyHostCache[host] - s.proxyHostMu.RUnlock() - if ok && now.Before(cached.ExpiresAt) { + cached, ok := s.proxyHostCache.Get(host) + if ok { if cached.Allowed { return nil } @@ -286,12 +282,9 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er } } - s.proxyHostMu.Lock() - s.proxyHostCache[host] = proxyHostCacheItem{ - Allowed: allowed, - ExpiresAt: now.Add(proxyHostCheckTTL), - } - s.proxyHostMu.Unlock() + s.proxyHostCache.Add(host, proxyHostCacheItem{ + Allowed: allowed, + }) if !allowed { return errors.New("private proxy targets are not allowed") diff --git a/api/playback/proxy_security_test.go b/api/playback/proxy_security_test.go index 5513310..84e8a0a 100644 --- a/api/playback/proxy_security_test.go +++ b/api/playback/proxy_security_test.go @@ -28,7 +28,10 @@ func TestNormalizeProxyURLRejectsPrivateIP(t *testing.T) { func TestProxyTokenScopeValidation(t *testing.T) { t.Parallel() - service := NewService(&fakeProxyQuerier{}, nil, Config{ProxyTokenSecret: "0123456789abcdef0123456789abcdef"}) + service, err := NewService(&fakeProxyQuerier{}, nil, Config{ProxyTokenSecret: "0123456789abcdef0123456789abcdef"}) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } token, err := service.issueProxyToken("https://example.com/playlist.m3u8", "", proxyScopeStream) if err != nil { t.Fatalf("failed to issue token: %v", err) diff --git a/api/playback/service_base.go b/api/playback/service_base.go index a4594fc..0149f41 100644 --- a/api/playback/service_base.go +++ b/api/playback/service_base.go @@ -12,12 +12,12 @@ import ( "strings" "sync" "time" + + "github.com/hashicorp/golang-lru/v2" ) const ( - showResolutionCacheTTL = 12 * time.Hour - playbackDataCacheTTL = 2 * time.Minute - providerProbeTimeout = 3 * time.Second + providerProbeTimeout = 3 * time.Second ) type Service struct { @@ -26,12 +26,11 @@ type Service struct { sqlDB *sql.DB db db.Querier proxyTokens *proxyTokenSigner - proxyHostMu sync.RWMutex - proxyHostCache map[string]proxyHostCacheItem + proxyHostCache *lru.Cache[string, proxyHostCacheItem] cacheMu sync.RWMutex - showResolution map[int]showResolutionCacheItem - playbackDataCache map[string]playbackDataCacheItem + showResolution *lru.Cache[int, showResolutionCacheItem] + playbackDataCache *lru.Cache[string, playbackDataCacheItem] } type Config struct { @@ -48,14 +47,12 @@ type sourceScore struct { } type showResolutionCacheItem struct { - ShowID string - Title string - ExpiresAt time.Time + ShowID string + Title string } type playbackDataCacheItem struct { - Data playbackBaseData - ExpiresAt time.Time + Data playbackBaseData } type playbackBaseData struct { @@ -84,8 +81,7 @@ type directProbeResult struct { } type proxyHostCacheItem struct { - Allowed bool - ExpiresAt time.Time + Allowed bool } type userPlaybackState struct { @@ -93,10 +89,23 @@ type userPlaybackState struct { StartTimeSeconds float64 } -func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) *Service { +func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) (*Service, error) { proxyTokens, err := newProxyTokenSigner(cfg.ProxyTokenSecret) if err != nil { - panic(fmt.Sprintf("failed to initialize proxy token signer: %v", err)) + return nil, fmt.Errorf("failed to initialize proxy token signer: %w", err) + } + + showResolution, err := lru.New[int, showResolutionCacheItem](5000) + if err != nil { + return nil, err + } + playbackDataCache, err := lru.New[string, playbackDataCacheItem](500) + if err != nil { + return nil, err + } + proxyHostCache, err := lru.New[string, proxyHostCacheItem](1000) + if err != nil { + return nil, err } return &Service{ @@ -105,10 +114,10 @@ func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) *Service { sqlDB: sqlDB, db: db, proxyTokens: proxyTokens, - proxyHostCache: make(map[string]proxyHostCacheItem), - showResolution: make(map[int]showResolutionCacheItem), - playbackDataCache: make(map[string]playbackDataCacheItem), - } + proxyHostCache: proxyHostCache, + showResolution: showResolution, + playbackDataCache: playbackDataCache, + }, nil } func (s *Service) BuildWatchPageData(ctx context.Context, malID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) { @@ -243,44 +252,21 @@ func (s *Service) fetchUserPlaybackStateAsync(ctx context.Context, userID string } func (s *Service) getPlaybackBaseDataCache(key string) (playbackBaseData, bool) { - now := time.Now() - - s.cacheMu.RLock() - item, ok := s.playbackDataCache[key] - s.cacheMu.RUnlock() + item, ok := s.playbackDataCache.Get(key) if !ok { return playbackBaseData{}, false } - - if now.After(item.ExpiresAt) { - s.cacheMu.Lock() - current, exists := s.playbackDataCache[key] - if exists && time.Now().After(current.ExpiresAt) { - delete(s.playbackDataCache, key) - } - s.cacheMu.Unlock() - return playbackBaseData{}, false - } - return clonePlaybackBaseData(item.Data), true } func (s *Service) setPlaybackBaseDataCache(key string, data playbackBaseData) { - s.cacheMu.Lock() - s.playbackDataCache[key] = playbackDataCacheItem{ - Data: clonePlaybackBaseData(data), - ExpiresAt: time.Now().Add(playbackDataCacheTTL), - } - s.cacheMu.Unlock() + s.playbackDataCache.Add(key, playbackDataCacheItem{ + Data: clonePlaybackBaseData(data), + }) } func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandidates []string) (string, string, error) { - s.cacheMu.RLock() - item, ok := s.showResolution[malID] - s.cacheMu.RUnlock() - - now := time.Now() - if ok && now.Before(item.ExpiresAt) && strings.TrimSpace(item.ShowID) != "" { + if item, ok := s.showResolution.Get(malID); ok && strings.TrimSpace(item.ShowID) != "" { return item.ShowID, item.Title, nil } @@ -289,13 +275,10 @@ func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandida return "", "", err } - s.cacheMu.Lock() - s.showResolution[malID] = showResolutionCacheItem{ - ShowID: showID, - Title: resolvedTitle, - ExpiresAt: now.Add(showResolutionCacheTTL), - } - s.cacheMu.Unlock() + s.showResolution.Add(malID, showResolutionCacheItem{ + ShowID: showID, + Title: resolvedTitle, + }) return showID, resolvedTitle, nil } diff --git a/go.mod b/go.mod index 5a10afa..4e774fd 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ require ( golang.org/x/net v0.53.0 ) +require github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + require ( github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect diff --git a/go.sum b/go.sum index cab9820..01abc20 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmg github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= diff --git a/internal/server/routes.go b/internal/server/routes.go index ba191ca..bff65e6 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -2,6 +2,7 @@ package server import ( "database/sql" + "fmt" "net/http" "path/filepath" "strings" @@ -72,9 +73,12 @@ func NewRouter(cfg Config) http.Handler { animeSvc := anime.NewService(cfg.JikanClient, cfg.DB) animeHandler := anime.NewHandler(animeSvc) - playbackSvc := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ + playbackSvc, err := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ ProxyTokenSecret: cfg.PlaybackProxySecret, }) + if err != nil { + panic(fmt.Sprintf("failed to initialize playback service: %v", err)) + } playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient) // Serve static files with no-cache headers