feat: bound in-memory caches with LRU eviction

This commit is contained in:
2026-05-10 18:04:29 +02:00
parent cc81347ace
commit d021a8eadd
6 changed files with 56 additions and 69 deletions

View File

@@ -20,7 +20,6 @@ const (
proxySegmentTokenTTL = 6 * time.Hour proxySegmentTokenTTL = 6 * time.Hour
proxySubtitleTokenTTL = 6 * time.Hour proxySubtitleTokenTTL = 6 * time.Hour
) )
const proxyHostCheckTTL = 2 * time.Minute
type proxyScope string type proxyScope string
@@ -262,11 +261,8 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
return nil return nil
} }
now := time.Now() cached, ok := s.proxyHostCache.Get(host)
s.proxyHostMu.RLock() if ok {
cached, ok := s.proxyHostCache[host]
s.proxyHostMu.RUnlock()
if ok && now.Before(cached.ExpiresAt) {
if cached.Allowed { if cached.Allowed {
return nil return nil
} }
@@ -286,12 +282,9 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
} }
} }
s.proxyHostMu.Lock() s.proxyHostCache.Add(host, proxyHostCacheItem{
s.proxyHostCache[host] = proxyHostCacheItem{ Allowed: allowed,
Allowed: allowed, })
ExpiresAt: now.Add(proxyHostCheckTTL),
}
s.proxyHostMu.Unlock()
if !allowed { if !allowed {
return errors.New("private proxy targets are not allowed") return errors.New("private proxy targets are not allowed")

View File

@@ -28,7 +28,10 @@ func TestNormalizeProxyURLRejectsPrivateIP(t *testing.T) {
func TestProxyTokenScopeValidation(t *testing.T) { func TestProxyTokenScopeValidation(t *testing.T) {
t.Parallel() 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) token, err := service.issueProxyToken("https://example.com/playlist.m3u8", "", proxyScopeStream)
if err != nil { if err != nil {
t.Fatalf("failed to issue token: %v", err) t.Fatalf("failed to issue token: %v", err)

View File

@@ -12,12 +12,12 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/hashicorp/golang-lru/v2"
) )
const ( const (
showResolutionCacheTTL = 12 * time.Hour providerProbeTimeout = 3 * time.Second
playbackDataCacheTTL = 2 * time.Minute
providerProbeTimeout = 3 * time.Second
) )
type Service struct { type Service struct {
@@ -26,12 +26,11 @@ type Service struct {
sqlDB *sql.DB sqlDB *sql.DB
db db.Querier db db.Querier
proxyTokens *proxyTokenSigner proxyTokens *proxyTokenSigner
proxyHostMu sync.RWMutex proxyHostCache *lru.Cache[string, proxyHostCacheItem]
proxyHostCache map[string]proxyHostCacheItem
cacheMu sync.RWMutex cacheMu sync.RWMutex
showResolution map[int]showResolutionCacheItem showResolution *lru.Cache[int, showResolutionCacheItem]
playbackDataCache map[string]playbackDataCacheItem playbackDataCache *lru.Cache[string, playbackDataCacheItem]
} }
type Config struct { type Config struct {
@@ -48,14 +47,12 @@ type sourceScore struct {
} }
type showResolutionCacheItem struct { type showResolutionCacheItem struct {
ShowID string ShowID string
Title string Title string
ExpiresAt time.Time
} }
type playbackDataCacheItem struct { type playbackDataCacheItem struct {
Data playbackBaseData Data playbackBaseData
ExpiresAt time.Time
} }
type playbackBaseData struct { type playbackBaseData struct {
@@ -84,8 +81,7 @@ type directProbeResult struct {
} }
type proxyHostCacheItem struct { type proxyHostCacheItem struct {
Allowed bool Allowed bool
ExpiresAt time.Time
} }
type userPlaybackState struct { type userPlaybackState struct {
@@ -93,10 +89,23 @@ type userPlaybackState struct {
StartTimeSeconds float64 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) proxyTokens, err := newProxyTokenSigner(cfg.ProxyTokenSecret)
if err != nil { 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{ return &Service{
@@ -105,10 +114,10 @@ func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) *Service {
sqlDB: sqlDB, sqlDB: sqlDB,
db: db, db: db,
proxyTokens: proxyTokens, proxyTokens: proxyTokens,
proxyHostCache: make(map[string]proxyHostCacheItem), proxyHostCache: proxyHostCache,
showResolution: make(map[int]showResolutionCacheItem), showResolution: showResolution,
playbackDataCache: make(map[string]playbackDataCacheItem), playbackDataCache: playbackDataCache,
} }, nil
} }
func (s *Service) BuildWatchPageData(ctx context.Context, malID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) { 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) { func (s *Service) getPlaybackBaseDataCache(key string) (playbackBaseData, bool) {
now := time.Now() item, ok := s.playbackDataCache.Get(key)
s.cacheMu.RLock()
item, ok := s.playbackDataCache[key]
s.cacheMu.RUnlock()
if !ok { if !ok {
return playbackBaseData{}, false 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 return clonePlaybackBaseData(item.Data), true
} }
func (s *Service) setPlaybackBaseDataCache(key string, data playbackBaseData) { func (s *Service) setPlaybackBaseDataCache(key string, data playbackBaseData) {
s.cacheMu.Lock() s.playbackDataCache.Add(key, playbackDataCacheItem{
s.playbackDataCache[key] = playbackDataCacheItem{ Data: clonePlaybackBaseData(data),
Data: clonePlaybackBaseData(data), })
ExpiresAt: time.Now().Add(playbackDataCacheTTL),
}
s.cacheMu.Unlock()
} }
func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandidates []string) (string, string, error) { func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandidates []string) (string, string, error) {
s.cacheMu.RLock() if item, ok := s.showResolution.Get(malID); ok && strings.TrimSpace(item.ShowID) != "" {
item, ok := s.showResolution[malID]
s.cacheMu.RUnlock()
now := time.Now()
if ok && now.Before(item.ExpiresAt) && strings.TrimSpace(item.ShowID) != "" {
return item.ShowID, item.Title, nil return item.ShowID, item.Title, nil
} }
@@ -289,13 +275,10 @@ func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandida
return "", "", err return "", "", err
} }
s.cacheMu.Lock() s.showResolution.Add(malID, showResolutionCacheItem{
s.showResolution[malID] = showResolutionCacheItem{ ShowID: showID,
ShowID: showID, Title: resolvedTitle,
Title: resolvedTitle, })
ExpiresAt: now.Add(showResolutionCacheTTL),
}
s.cacheMu.Unlock()
return showID, resolvedTitle, nil return showID, resolvedTitle, nil
} }

2
go.mod
View File

@@ -12,6 +12,8 @@ require (
golang.org/x/net v0.53.0 golang.org/x/net v0.53.0
) )
require github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
require ( require (
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect

2
go.sum
View File

@@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=

View File

@@ -2,6 +2,7 @@ package server
import ( import (
"database/sql" "database/sql"
"fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -72,9 +73,12 @@ func NewRouter(cfg Config) http.Handler {
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB) animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
animeHandler := anime.NewHandler(animeSvc) 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, ProxyTokenSecret: cfg.PlaybackProxySecret,
}) })
if err != nil {
panic(fmt.Sprintf("failed to initialize playback service: %v", err))
}
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient) playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
// Serve static files with no-cache headers // Serve static files with no-cache headers