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
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{
s.proxyHostCache.Add(host, proxyHostCacheItem{
Allowed: allowed,
ExpiresAt: now.Add(proxyHostCheckTTL),
}
s.proxyHostMu.Unlock()
})
if !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) {
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)

View File

@@ -12,11 +12,11 @@ import (
"strings"
"sync"
"time"
"github.com/hashicorp/golang-lru/v2"
)
const (
showResolutionCacheTTL = 12 * time.Hour
playbackDataCacheTTL = 2 * time.Minute
providerProbeTimeout = 3 * time.Second
)
@@ -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 {
@@ -50,12 +49,10 @@ type sourceScore struct {
type showResolutionCacheItem struct {
ShowID string
Title string
ExpiresAt time.Time
}
type playbackDataCacheItem struct {
Data playbackBaseData
ExpiresAt time.Time
}
type playbackBaseData struct {
@@ -85,7 +82,6 @@ type directProbeResult struct {
type proxyHostCacheItem struct {
Allowed bool
ExpiresAt time.Time
}
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{
s.playbackDataCache.Add(key, playbackDataCacheItem{
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) {
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{
s.showResolution.Add(malID, showResolutionCacheItem{
ShowID: showID,
Title: resolvedTitle,
ExpiresAt: now.Add(showResolutionCacheTTL),
}
s.cacheMu.Unlock()
})
return showID, resolvedTitle, nil
}

2
go.mod
View File

@@ -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

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/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=

View File

@@ -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