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