feat: bound in-memory caches with LRU eviction
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
2
go.mod
2
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user