mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-04 20:43:03 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CatalogCache provides caching for catalog operations
|
||||
type CatalogCache struct {
|
||||
service *Service
|
||||
keys *KeyBuilder
|
||||
}
|
||||
|
||||
// NewCatalogCache creates a new catalog cache
|
||||
func NewCatalogCache(service *Service, namespace string) *CatalogCache {
|
||||
return &CatalogCache{
|
||||
service: service,
|
||||
keys: NewKeyBuilder(namespace),
|
||||
}
|
||||
}
|
||||
|
||||
// GetDashboard retrieves cached dashboard data
|
||||
func (cc *CatalogCache) GetDashboard(ctx context.Context, userID string, target interface{}) error {
|
||||
key := cc.keys.CatalogDashboardKey(userID)
|
||||
return cc.service.Get(ctx, key, target)
|
||||
}
|
||||
|
||||
// SetDashboard stores dashboard data in cache
|
||||
func (cc *CatalogCache) SetDashboard(ctx context.Context, userID string, data interface{}) error {
|
||||
key := cc.keys.CatalogDashboardKey(userID)
|
||||
return cc.service.Set(ctx, key, data, TTLCatalog)
|
||||
}
|
||||
|
||||
// InvalidateDashboard removes cached dashboard data
|
||||
func (cc *CatalogCache) InvalidateDashboard(ctx context.Context, userID string) error {
|
||||
key := cc.keys.CatalogDashboardKey(userID)
|
||||
return cc.service.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// GetDiscover retrieves cached discover sections
|
||||
func (cc *CatalogCache) GetDiscover(ctx context.Context, genre, mediaType string, page int, target interface{}) error {
|
||||
key := cc.keys.CatalogDiscoverKey(genre, mediaType, page)
|
||||
return cc.service.Get(ctx, key, target)
|
||||
}
|
||||
|
||||
// SetDiscover stores discover sections in cache
|
||||
func (cc *CatalogCache) SetDiscover(ctx context.Context, genre, mediaType string, page int, data interface{}) error {
|
||||
key := cc.keys.CatalogDiscoverKey(genre, mediaType, page)
|
||||
return cc.service.Set(ctx, key, data, TTLCatalog)
|
||||
}
|
||||
|
||||
// GetSearch retrieves cached search results
|
||||
func (cc *CatalogCache) GetSearch(ctx context.Context, query, genre, mediaType string, target interface{}) error {
|
||||
key := cc.keys.CatalogSearchKey(query, genre, mediaType)
|
||||
return cc.service.Get(ctx, key, target)
|
||||
}
|
||||
|
||||
// SetSearch stores search results in cache
|
||||
func (cc *CatalogCache) SetSearch(ctx context.Context, query, genre, mediaType string, data interface{}) error {
|
||||
key := cc.keys.CatalogSearchKey(query, genre, mediaType)
|
||||
return cc.service.Set(ctx, key, data, TTLSearch)
|
||||
}
|
||||
|
||||
// GetWatchLater retrieves cached watch later list
|
||||
func (cc *CatalogCache) GetWatchLater(ctx context.Context, userID string, target interface{}) error {
|
||||
key := cc.keys.WatchLaterKey(userID)
|
||||
return cc.service.Get(ctx, key, target)
|
||||
}
|
||||
|
||||
// SetWatchLater stores watch later list in cache
|
||||
func (cc *CatalogCache) SetWatchLater(ctx context.Context, userID string, data interface{}) error {
|
||||
key := cc.keys.WatchLaterKey(userID)
|
||||
return cc.service.Set(ctx, key, data, TTLCatalog)
|
||||
}
|
||||
|
||||
// InvalidateWatchLater removes cached watch later list
|
||||
func (cc *CatalogCache) InvalidateWatchLater(ctx context.Context, userID string) error {
|
||||
key := cc.keys.WatchLaterKey(userID)
|
||||
return cc.service.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// GetContinueWatching retrieves cached continue watching list
|
||||
func (cc *CatalogCache) GetContinueWatching(ctx context.Context, userID string, target interface{}) error {
|
||||
key := cc.keys.ContinueWatchingKey(userID)
|
||||
return cc.service.Get(ctx, key, target)
|
||||
}
|
||||
|
||||
// SetContinueWatching stores continue watching list in cache
|
||||
func (cc *CatalogCache) SetContinueWatching(ctx context.Context, userID string, data interface{}) error {
|
||||
key := cc.keys.ContinueWatchingKey(userID)
|
||||
return cc.service.Set(ctx, key, data, TTLCatalog)
|
||||
}
|
||||
|
||||
// InvalidateContinueWatching removes cached continue watching list
|
||||
func (cc *CatalogCache) InvalidateContinueWatching(ctx context.Context, userID string) error {
|
||||
key := cc.keys.ContinueWatchingKey(userID)
|
||||
return cc.service.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// InvalidateUserCatalog removes all cached catalog data for a user
|
||||
func (cc *CatalogCache) InvalidateUserCatalog(ctx context.Context, userID string) error {
|
||||
keys := []string{
|
||||
cc.keys.CatalogDashboardKey(userID),
|
||||
cc.keys.WatchLaterKey(userID),
|
||||
cc.keys.ContinueWatchingKey(userID),
|
||||
}
|
||||
|
||||
return cc.service.Delete(ctx, keys...)
|
||||
}
|
||||
|
||||
// GetRecommendations retrieves cached recommendations
|
||||
func (cc *CatalogCache) GetRecommendations(ctx context.Context, userID string, target interface{}) error {
|
||||
key := cc.keys.RecommendationKey(userID)
|
||||
return cc.service.Get(ctx, key, target)
|
||||
}
|
||||
|
||||
// SetRecommendations stores recommendations in cache
|
||||
func (cc *CatalogCache) SetRecommendations(ctx context.Context, userID string, data interface{}) error {
|
||||
key := cc.keys.RecommendationKey(userID)
|
||||
return cc.service.Set(ctx, key, data, TTLRecommendation)
|
||||
}
|
||||
|
||||
// InvalidateRecommendations removes cached recommendations
|
||||
func (cc *CatalogCache) InvalidateRecommendations(ctx context.Context, userID string) error {
|
||||
key := cc.keys.RecommendationKey(userID)
|
||||
return cc.service.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// WarmupCache pre-populates cache with common queries
|
||||
func (cc *CatalogCache) WarmupCache(ctx context.Context, warmupFunc func(ctx context.Context) error) error {
|
||||
if warmupFunc == nil {
|
||||
return fmt.Errorf("warmup function is required")
|
||||
}
|
||||
|
||||
return warmupFunc(ctx)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadProgress represents real-time download progress
|
||||
type DownloadProgress struct {
|
||||
JobID string `json:"jobId"`
|
||||
Status string `json:"status"`
|
||||
ProgressPercent int `json:"progressPercent"`
|
||||
BytesTotal int64 `json:"bytesTotal"`
|
||||
BytesDownloaded int64 `json:"bytesDownloaded"`
|
||||
DownloadSpeedMbps float64 `json:"downloadSpeedMbps"`
|
||||
EtaSeconds int `json:"etaSeconds"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DownloadCache provides caching for download operations
|
||||
type DownloadCache struct {
|
||||
service *Service
|
||||
keys *KeyBuilder
|
||||
}
|
||||
|
||||
// NewDownloadCache creates a new download cache
|
||||
func NewDownloadCache(service *Service, namespace string) *DownloadCache {
|
||||
return &DownloadCache{
|
||||
service: service,
|
||||
keys: NewKeyBuilder(namespace),
|
||||
}
|
||||
}
|
||||
|
||||
// SetProgress stores download progress in cache
|
||||
func (dc *DownloadCache) SetProgress(ctx context.Context, progress DownloadProgress) error {
|
||||
key := dc.keys.DownloadJobKey(progress.JobID)
|
||||
progress.UpdatedAt = time.Now().UTC()
|
||||
return dc.service.Set(ctx, key, progress, TTLDownload)
|
||||
}
|
||||
|
||||
// GetProgress retrieves download progress from cache
|
||||
func (dc *DownloadCache) GetProgress(ctx context.Context, jobID string) (*DownloadProgress, error) {
|
||||
key := dc.keys.DownloadJobKey(jobID)
|
||||
var progress DownloadProgress
|
||||
|
||||
if err := dc.service.Get(ctx, key, &progress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &progress, nil
|
||||
}
|
||||
|
||||
// DeleteProgress removes download progress from cache
|
||||
func (dc *DownloadCache) DeleteProgress(ctx context.Context, jobID string) error {
|
||||
key := dc.keys.DownloadJobKey(jobID)
|
||||
return dc.service.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// GetUserDownloads retrieves cached download list for a user
|
||||
func (dc *DownloadCache) GetUserDownloads(ctx context.Context, userID, status string, target interface{}) error {
|
||||
key := dc.keys.DownloadListKey(userID, status)
|
||||
return dc.service.Get(ctx, key, target)
|
||||
}
|
||||
|
||||
// SetUserDownloads stores download list in cache
|
||||
func (dc *DownloadCache) SetUserDownloads(ctx context.Context, userID, status string, data interface{}) error {
|
||||
key := dc.keys.DownloadListKey(userID, status)
|
||||
return dc.service.Set(ctx, key, data, TTLDownload)
|
||||
}
|
||||
|
||||
// InvalidateUserDownloads removes cached download list
|
||||
func (dc *DownloadCache) InvalidateUserDownloads(ctx context.Context, userID string) error {
|
||||
// Invalidate all status variations
|
||||
statuses := []string{"", "queued", "preparing", "downloading", "completed", "failed", "cancelled"}
|
||||
keys := make([]string, 0, len(statuses))
|
||||
|
||||
for _, status := range statuses {
|
||||
keys = append(keys, dc.keys.DownloadListKey(userID, status))
|
||||
}
|
||||
|
||||
return dc.service.Delete(ctx, keys...)
|
||||
}
|
||||
|
||||
// UpdateProgressField updates a specific field of download progress
|
||||
func (dc *DownloadCache) UpdateProgressField(ctx context.Context, jobID string, updateFunc func(*DownloadProgress)) error {
|
||||
progress, err := dc.GetProgress(ctx, jobID)
|
||||
if err != nil {
|
||||
if err == ErrCacheMiss {
|
||||
// Create new progress entry
|
||||
progress = &DownloadProgress{
|
||||
JobID: jobID,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
updateFunc(progress)
|
||||
return dc.SetProgress(ctx, *progress)
|
||||
}
|
||||
|
||||
// IncrementDownloadedBytes atomically increments downloaded bytes
|
||||
func (dc *DownloadCache) IncrementDownloadedBytes(ctx context.Context, jobID string, bytes int64) error {
|
||||
return dc.UpdateProgressField(ctx, jobID, func(p *DownloadProgress) {
|
||||
p.BytesDownloaded += bytes
|
||||
if p.BytesTotal > 0 {
|
||||
p.ProgressPercent = int((p.BytesDownloaded * 100) / p.BytesTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SetDownloadSpeed updates the download speed
|
||||
func (dc *DownloadCache) SetDownloadSpeed(ctx context.Context, jobID string, speedMbps float64) error {
|
||||
return dc.UpdateProgressField(ctx, jobID, func(p *DownloadProgress) {
|
||||
p.DownloadSpeedMbps = speedMbps
|
||||
|
||||
// Calculate ETA if we have speed and remaining bytes
|
||||
if speedMbps > 0 && p.BytesTotal > 0 {
|
||||
remainingBytes := p.BytesTotal - p.BytesDownloaded
|
||||
if remainingBytes > 0 {
|
||||
// Convert Mbps to bytes per second
|
||||
bytesPerSecond := (speedMbps * 1024 * 1024) / 8
|
||||
p.EtaSeconds = int(float64(remainingBytes) / bytesPerSecond)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// GetActiveDownloads retrieves all active download jobs
|
||||
func (dc *DownloadCache) GetActiveDownloads(ctx context.Context) ([]DownloadProgress, error) {
|
||||
pattern := dc.keys.Build(PrefixDownload, "job", "*")
|
||||
keys, err := dc.service.Keys(ctx, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
downloads := make([]DownloadProgress, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
var progress DownloadProgress
|
||||
if err := dc.service.Get(ctx, key, &progress); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only include active downloads
|
||||
if progress.Status == "downloading" || progress.Status == "preparing" {
|
||||
downloads = append(downloads, progress)
|
||||
}
|
||||
}
|
||||
|
||||
return downloads, nil
|
||||
}
|
||||
|
||||
// CleanupStaleProgress removes progress entries that haven't been updated recently
|
||||
func (dc *DownloadCache) CleanupStaleProgress(ctx context.Context, maxAge time.Duration) error {
|
||||
pattern := dc.keys.Build(PrefixDownload, "job", "*")
|
||||
keys, err := dc.service.Keys(ctx, pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
toDelete := make([]string, 0)
|
||||
|
||||
for _, key := range keys {
|
||||
var progress DownloadProgress
|
||||
if err := dc.service.Get(ctx, key, &progress); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if now.Sub(progress.UpdatedAt) > maxAge {
|
||||
toDelete = append(toDelete, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toDelete) > 0 {
|
||||
return dc.service.Delete(ctx, toDelete...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkSetProgress stores multiple download progress entries at once
|
||||
func (dc *DownloadCache) BulkSetProgress(ctx context.Context, progressList []DownloadProgress) error {
|
||||
if len(progressList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pairs := make(map[string]interface{}, len(progressList))
|
||||
for _, progress := range progressList {
|
||||
key := dc.keys.DownloadJobKey(progress.JobID)
|
||||
progress.UpdatedAt = time.Now().UTC()
|
||||
pairs[key] = progress
|
||||
}
|
||||
|
||||
if err := dc.service.MSet(ctx, pairs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set TTL for each key
|
||||
for key := range pairs {
|
||||
if err := dc.service.Expire(ctx, key, TTLDownload); err != nil {
|
||||
return fmt.Errorf("failed to set TTL for %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/tdvorak/seen/backend/internal/config"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func NewClient(ctx context.Context, cfg config.CacheConfig, log *zap.Logger) (*redis.Client, error) {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Addr,
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
DialTimeout: 5 * time.Second,
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
})
|
||||
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("ping dragonfly: %w", err)
|
||||
}
|
||||
|
||||
log.Info("dragonfly connected", zap.String("addr", cfg.Addr), zap.Int("db", cfg.DB))
|
||||
return client, nil
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Key prefixes for different data types
|
||||
const (
|
||||
PrefixSession = "session"
|
||||
PrefixUser = "user"
|
||||
PrefixCatalog = "catalog"
|
||||
PrefixDownload = "download"
|
||||
PrefixRateLimit = "ratelimit"
|
||||
PrefixLock = "lock"
|
||||
PrefixSearch = "search"
|
||||
PrefixRecommendation = "recommendation"
|
||||
)
|
||||
|
||||
// TTL constants
|
||||
const (
|
||||
TTLSession = 24 * time.Hour
|
||||
TTLUser = 15 * time.Minute
|
||||
TTLCatalog = 5 * time.Minute
|
||||
TTLDownload = 30 * time.Second
|
||||
TTLRateLimit = 1 * time.Minute
|
||||
TTLLock = 30 * time.Second
|
||||
TTLSearch = 10 * time.Minute
|
||||
TTLRecommendation = 1 * time.Hour
|
||||
)
|
||||
|
||||
// KeyBuilder provides methods to build cache keys
|
||||
type KeyBuilder struct {
|
||||
namespace string
|
||||
}
|
||||
|
||||
// NewKeyBuilder creates a new key builder with a namespace
|
||||
func NewKeyBuilder(namespace string) *KeyBuilder {
|
||||
return &KeyBuilder{namespace: namespace}
|
||||
}
|
||||
|
||||
// Build creates a cache key from parts
|
||||
func (kb *KeyBuilder) Build(parts ...string) string {
|
||||
if kb.namespace == "" {
|
||||
return join(parts...)
|
||||
}
|
||||
return join(append([]string{kb.namespace}, parts...)...)
|
||||
}
|
||||
|
||||
// SessionKey builds a key for session data
|
||||
func (kb *KeyBuilder) SessionKey(sessionID string) string {
|
||||
return kb.Build(PrefixSession, sessionID)
|
||||
}
|
||||
|
||||
// UserKey builds a key for user data
|
||||
func (kb *KeyBuilder) UserKey(userID string) string {
|
||||
return kb.Build(PrefixUser, userID)
|
||||
}
|
||||
|
||||
// UserProfileKey builds a key for user profile data
|
||||
func (kb *KeyBuilder) UserProfileKey(userID string) string {
|
||||
return kb.Build(PrefixUser, userID, "profile")
|
||||
}
|
||||
|
||||
// CatalogDashboardKey builds a key for dashboard data
|
||||
func (kb *KeyBuilder) CatalogDashboardKey(userID string) string {
|
||||
return kb.Build(PrefixCatalog, "dashboard", userID)
|
||||
}
|
||||
|
||||
// CatalogDiscoverKey builds a key for discover sections
|
||||
func (kb *KeyBuilder) CatalogDiscoverKey(genre, mediaType string, page int) string {
|
||||
return kb.Build(PrefixCatalog, "discover", genre, mediaType, fmt.Sprintf("page:%d", page))
|
||||
}
|
||||
|
||||
// CatalogSearchKey builds a key for search results
|
||||
func (kb *KeyBuilder) CatalogSearchKey(query, genre, mediaType string) string {
|
||||
return kb.Build(PrefixSearch, query, genre, mediaType)
|
||||
}
|
||||
|
||||
// DownloadJobKey builds a key for download job data
|
||||
func (kb *KeyBuilder) DownloadJobKey(jobID string) string {
|
||||
return kb.Build(PrefixDownload, "job", jobID)
|
||||
}
|
||||
|
||||
// DownloadListKey builds a key for user's download list
|
||||
func (kb *KeyBuilder) DownloadListKey(userID string, status string) string {
|
||||
return kb.Build(PrefixDownload, "list", userID, status)
|
||||
}
|
||||
|
||||
// RateLimitKey builds a key for rate limiting
|
||||
func (kb *KeyBuilder) RateLimitKey(identifier, action string) string {
|
||||
return kb.Build(PrefixRateLimit, identifier, action)
|
||||
}
|
||||
|
||||
// LockKey builds a key for distributed locks
|
||||
func (kb *KeyBuilder) LockKey(resource string) string {
|
||||
return kb.Build(PrefixLock, resource)
|
||||
}
|
||||
|
||||
// RecommendationKey builds a key for user recommendations
|
||||
func (kb *KeyBuilder) RecommendationKey(userID string) string {
|
||||
return kb.Build(PrefixRecommendation, userID)
|
||||
}
|
||||
|
||||
// WatchLaterKey builds a key for watch later list
|
||||
func (kb *KeyBuilder) WatchLaterKey(userID string) string {
|
||||
return kb.Build(PrefixCatalog, "watchlater", userID)
|
||||
}
|
||||
|
||||
// ContinueWatchingKey builds a key for continue watching list
|
||||
func (kb *KeyBuilder) ContinueWatchingKey(userID string) string {
|
||||
return kb.Build(PrefixCatalog, "continue", userID)
|
||||
}
|
||||
|
||||
// join concatenates strings with a colon separator
|
||||
func join(parts ...string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := parts[0]
|
||||
for i := 1; i < len(parts); i++ {
|
||||
if parts[i] != "" {
|
||||
result += ":" + parts[i]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/tdvorak/seen/backend/internal/config"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Manager provides centralized cache management
|
||||
type Manager struct {
|
||||
client *redis.Client
|
||||
service *Service
|
||||
session *SessionCache
|
||||
catalog *CatalogCache
|
||||
download *DownloadCache
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewManager creates a new cache manager with all cache services
|
||||
func NewManager(ctx context.Context, cfg config.CacheConfig, log *zap.Logger) (*Manager, error) {
|
||||
client, err := NewClient(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache client: %w", err)
|
||||
}
|
||||
|
||||
service := NewService(client)
|
||||
namespace := "seen"
|
||||
|
||||
manager := &Manager{
|
||||
client: client,
|
||||
service: service,
|
||||
session: NewSessionCache(service, namespace),
|
||||
catalog: NewCatalogCache(service, namespace),
|
||||
download: NewDownloadCache(service, namespace),
|
||||
log: log,
|
||||
}
|
||||
|
||||
// Start background cleanup tasks
|
||||
go manager.startCleanupTasks(ctx)
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// Client returns the underlying Redis client
|
||||
func (m *Manager) Client() *redis.Client {
|
||||
return m.client
|
||||
}
|
||||
|
||||
// Service returns the cache service
|
||||
func (m *Manager) Service() *Service {
|
||||
return m.service
|
||||
}
|
||||
|
||||
// Session returns the session cache
|
||||
func (m *Manager) Session() *SessionCache {
|
||||
return m.session
|
||||
}
|
||||
|
||||
// Catalog returns the catalog cache
|
||||
func (m *Manager) Catalog() *CatalogCache {
|
||||
return m.catalog
|
||||
}
|
||||
|
||||
// Download returns the download cache
|
||||
func (m *Manager) Download() *DownloadCache {
|
||||
return m.download
|
||||
}
|
||||
|
||||
// Ping checks if the cache is responsive
|
||||
func (m *Manager) Ping(ctx context.Context) error {
|
||||
return m.service.Ping(ctx)
|
||||
}
|
||||
|
||||
// Close closes all cache connections
|
||||
func (m *Manager) Close() error {
|
||||
m.log.Info("closing cache connections")
|
||||
return m.service.Close()
|
||||
}
|
||||
|
||||
// Stats returns cache statistics
|
||||
func (m *Manager) Stats(ctx context.Context) (map[string]interface{}, error) {
|
||||
info, err := m.client.Info(ctx, "stats", "memory", "keyspace").Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
||||
}
|
||||
|
||||
dbSize, err := m.client.DBSize(ctx).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get db size: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"dbSize": dbSize,
|
||||
"info": info,
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// FlushAll clears all cache data (use with extreme caution!)
|
||||
func (m *Manager) FlushAll(ctx context.Context) error {
|
||||
m.log.Warn("flushing all cache data")
|
||||
return m.service.FlushDB(ctx)
|
||||
}
|
||||
|
||||
// startCleanupTasks starts background tasks for cache maintenance
|
||||
func (m *Manager) startCleanupTasks(ctx context.Context) {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
m.log.Info("stopping cache cleanup tasks")
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.runCleanupTasks(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runCleanupTasks performs periodic cache maintenance
|
||||
func (m *Manager) runCleanupTasks(ctx context.Context) {
|
||||
// Cleanup stale download progress (older than 1 hour)
|
||||
if err := m.download.CleanupStaleProgress(ctx, 1*time.Hour); err != nil {
|
||||
m.log.Error("failed to cleanup stale download progress", zap.Error(err))
|
||||
}
|
||||
|
||||
// Log cache stats
|
||||
stats, err := m.Stats(ctx)
|
||||
if err != nil {
|
||||
m.log.Error("failed to get cache stats", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if dbSize, ok := stats["dbSize"].(int64); ok {
|
||||
m.log.Debug("cache stats", zap.Int64("keys", dbSize))
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateUser removes all cached data for a user
|
||||
func (m *Manager) InvalidateUser(ctx context.Context, userID string) error {
|
||||
m.log.Info("invalidating user cache", zap.String("userId", userID))
|
||||
|
||||
// Invalidate user data
|
||||
if err := m.session.DeleteUser(ctx, userID); err != nil {
|
||||
m.log.Error("failed to delete user cache", zap.Error(err))
|
||||
}
|
||||
|
||||
// Invalidate user sessions
|
||||
if err := m.session.InvalidateUserSessions(ctx, userID); err != nil {
|
||||
m.log.Error("failed to invalidate user sessions", zap.Error(err))
|
||||
}
|
||||
|
||||
// Invalidate catalog data
|
||||
if err := m.catalog.InvalidateUserCatalog(ctx, userID); err != nil {
|
||||
m.log.Error("failed to invalidate user catalog", zap.Error(err))
|
||||
}
|
||||
|
||||
// Invalidate download data
|
||||
if err := m.download.InvalidateUserDownloads(ctx, userID); err != nil {
|
||||
m.log.Error("failed to invalidate user downloads", zap.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WarmupCache pre-populates cache with common data
|
||||
func (m *Manager) WarmupCache(ctx context.Context) error {
|
||||
m.log.Info("warming up cache")
|
||||
|
||||
// Add warmup logic here as needed
|
||||
// For example, pre-cache popular catalog sections
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthCheck performs a comprehensive health check
|
||||
func (m *Manager) HealthCheck(ctx context.Context) error {
|
||||
// Check basic connectivity
|
||||
if err := m.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("ping failed: %w", err)
|
||||
}
|
||||
|
||||
// Test write operation
|
||||
testKey := "health:check"
|
||||
testValue := map[string]interface{}{
|
||||
"timestamp": time.Now().UTC(),
|
||||
"test": true,
|
||||
}
|
||||
|
||||
if err := m.service.Set(ctx, testKey, testValue, 10*time.Second); err != nil {
|
||||
return fmt.Errorf("write test failed: %w", err)
|
||||
}
|
||||
|
||||
// Test read operation
|
||||
var readValue map[string]interface{}
|
||||
if err := m.service.Get(ctx, testKey, &readValue); err != nil {
|
||||
return fmt.Errorf("read test failed: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup test key
|
||||
if err := m.service.Delete(ctx, testKey); err != nil {
|
||||
m.log.Warn("failed to cleanup health check key", zap.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKeysByPattern retrieves all keys matching a pattern
|
||||
func (m *Manager) GetKeysByPattern(ctx context.Context, pattern string) ([]string, error) {
|
||||
return m.service.Keys(ctx, pattern)
|
||||
}
|
||||
|
||||
// DeleteKeysByPattern deletes all keys matching a pattern
|
||||
func (m *Manager) DeleteKeysByPattern(ctx context.Context, pattern string) error {
|
||||
keys, err := m.service.Keys(ctx, pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.log.Info("deleting keys by pattern",
|
||||
zap.String("pattern", pattern),
|
||||
zap.Int("count", len(keys)))
|
||||
|
||||
return m.service.Delete(ctx, keys...)
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCacheMiss = errors.New("cache miss")
|
||||
ErrCacheSet = errors.New("cache set failed")
|
||||
)
|
||||
|
||||
// Service provides high-level caching operations
|
||||
type Service struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewService creates a new cache service
|
||||
func NewService(client *redis.Client) *Service {
|
||||
return &Service{client: client}
|
||||
}
|
||||
|
||||
// Get retrieves a value from cache and unmarshals it into the target
|
||||
func (s *Service) Get(ctx context.Context, key string, target interface{}) error {
|
||||
data, err := s.client.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return ErrCacheMiss
|
||||
}
|
||||
return fmt.Errorf("cache get: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return fmt.Errorf("cache unmarshal: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set stores a value in cache with the given TTL
|
||||
func (s *Service) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cache marshal: %w", err)
|
||||
}
|
||||
|
||||
if err := s.client.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrCacheSet, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a key from cache
|
||||
func (s *Service) Delete(ctx context.Context, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.client.Del(ctx, keys...).Err(); err != nil {
|
||||
return fmt.Errorf("cache delete: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists checks if a key exists in cache
|
||||
func (s *Service) Exists(ctx context.Context, key string) (bool, error) {
|
||||
count, err := s.client.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cache exists: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Expire sets a TTL on an existing key
|
||||
func (s *Service) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||
if err := s.client.Expire(ctx, key, ttl).Err(); err != nil {
|
||||
return fmt.Errorf("cache expire: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TTL returns the remaining time to live for a key
|
||||
func (s *Service) TTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
ttl, err := s.client.TTL(ctx, key).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cache ttl: %w", err)
|
||||
}
|
||||
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// Increment atomically increments a counter
|
||||
func (s *Service) Increment(ctx context.Context, key string) (int64, error) {
|
||||
val, err := s.client.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cache increment: %w", err)
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// IncrementBy atomically increments a counter by a specific amount
|
||||
func (s *Service) IncrementBy(ctx context.Context, key string, amount int64) (int64, error) {
|
||||
val, err := s.client.IncrBy(ctx, key, amount).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cache increment by: %w", err)
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// SetNX sets a key only if it doesn't exist (useful for locks)
|
||||
func (s *Service) SetNX(ctx context.Context, key string, value interface{}, ttl time.Duration) (bool, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cache marshal: %w", err)
|
||||
}
|
||||
|
||||
ok, err := s.client.SetNX(ctx, key, data, ttl).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cache setnx: %w", err)
|
||||
}
|
||||
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// GetSet atomically sets a new value and returns the old value
|
||||
func (s *Service) GetSet(ctx context.Context, key string, newValue interface{}, target interface{}) error {
|
||||
data, err := json.Marshal(newValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cache marshal: %w", err)
|
||||
}
|
||||
|
||||
oldData, err := s.client.GetSet(ctx, key, data).Bytes()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return ErrCacheMiss
|
||||
}
|
||||
return fmt.Errorf("cache getset: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(oldData, target); err != nil {
|
||||
return fmt.Errorf("cache unmarshal: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MGet retrieves multiple keys at once
|
||||
func (s *Service) MGet(ctx context.Context, keys ...string) ([]interface{}, error) {
|
||||
if len(keys) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
values, err := s.client.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache mget: %w", err)
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// MSet sets multiple key-value pairs at once
|
||||
func (s *Service) MSet(ctx context.Context, pairs map[string]interface{}) error {
|
||||
if len(pairs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert map to slice of interface{} for Redis
|
||||
args := make([]interface{}, 0, len(pairs)*2)
|
||||
for key, value := range pairs {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cache marshal %s: %w", key, err)
|
||||
}
|
||||
args = append(args, key, data)
|
||||
}
|
||||
|
||||
if err := s.client.MSet(ctx, args...).Err(); err != nil {
|
||||
return fmt.Errorf("cache mset: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FlushDB clears all keys in the current database (use with caution!)
|
||||
func (s *Service) FlushDB(ctx context.Context) error {
|
||||
if err := s.client.FlushDB(ctx).Err(); err != nil {
|
||||
return fmt.Errorf("cache flush: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keys returns all keys matching a pattern
|
||||
func (s *Service) Keys(ctx context.Context, pattern string) ([]string, error) {
|
||||
keys, err := s.client.Keys(ctx, pattern).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache keys: %w", err)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// Scan iterates over keys matching a pattern (better than Keys for large datasets)
|
||||
func (s *Service) Scan(ctx context.Context, pattern string, count int64) ([]string, error) {
|
||||
var keys []string
|
||||
var cursor uint64
|
||||
|
||||
for {
|
||||
var batch []string
|
||||
var err error
|
||||
|
||||
batch, cursor, err = s.client.Scan(ctx, cursor, pattern, count).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache scan: %w", err)
|
||||
}
|
||||
|
||||
keys = append(keys, batch...)
|
||||
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// Ping checks if the cache is responsive
|
||||
func (s *Service) Ping(ctx context.Context) error {
|
||||
return s.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// Close closes the cache connection
|
||||
func (s *Service) Close() error {
|
||||
return s.client.Close()
|
||||
}
|
||||
|
||||
// Client returns the underlying Redis client for advanced operations
|
||||
func (s *Service) Client() *redis.Client {
|
||||
return s.client
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SessionData represents cached session information
|
||||
type SessionData struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
UserID string `json:"userId"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
IP string `json:"ip"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// SessionCache provides session caching operations
|
||||
type SessionCache struct {
|
||||
service *Service
|
||||
keys *KeyBuilder
|
||||
}
|
||||
|
||||
// NewSessionCache creates a new session cache
|
||||
func NewSessionCache(service *Service, namespace string) *SessionCache {
|
||||
return &SessionCache{
|
||||
service: service,
|
||||
keys: NewKeyBuilder(namespace),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSession stores session data in cache
|
||||
func (sc *SessionCache) SetSession(ctx context.Context, session SessionData) error {
|
||||
key := sc.keys.SessionKey(session.SessionID)
|
||||
ttl := time.Until(session.ExpiresAt)
|
||||
|
||||
if ttl <= 0 {
|
||||
return fmt.Errorf("session already expired")
|
||||
}
|
||||
|
||||
return sc.service.Set(ctx, key, session, ttl)
|
||||
}
|
||||
|
||||
// GetSession retrieves session data from cache
|
||||
func (sc *SessionCache) GetSession(ctx context.Context, sessionID string) (*SessionData, error) {
|
||||
key := sc.keys.SessionKey(sessionID)
|
||||
var session SessionData
|
||||
|
||||
if err := sc.service.Get(ctx, key, &session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes session data from cache
|
||||
func (sc *SessionCache) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
key := sc.keys.SessionKey(sessionID)
|
||||
return sc.service.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// GetSessionByRefreshToken retrieves session by refresh token
|
||||
// Note: This requires scanning, which is slower. Consider using a secondary index.
|
||||
func (sc *SessionCache) GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*SessionData, error) {
|
||||
pattern := sc.keys.Build(PrefixSession, "*")
|
||||
keys, err := sc.service.Keys(ctx, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
var session SessionData
|
||||
if err := sc.service.Get(ctx, key, &session); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if session.RefreshToken == refreshToken {
|
||||
return &session, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrCacheMiss
|
||||
}
|
||||
|
||||
// ExtendSession extends the TTL of a session
|
||||
func (sc *SessionCache) ExtendSession(ctx context.Context, sessionID string, duration time.Duration) error {
|
||||
key := sc.keys.SessionKey(sessionID)
|
||||
return sc.service.Expire(ctx, key, duration)
|
||||
}
|
||||
|
||||
// UserData represents cached user information
|
||||
type UserData struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
CachedAt time.Time `json:"cachedAt"`
|
||||
}
|
||||
|
||||
// SetUser stores user data in cache
|
||||
func (sc *SessionCache) SetUser(ctx context.Context, user UserData) error {
|
||||
key := sc.keys.UserKey(user.ID)
|
||||
return sc.service.Set(ctx, key, user, TTLUser)
|
||||
}
|
||||
|
||||
// GetUser retrieves user data from cache
|
||||
func (sc *SessionCache) GetUser(ctx context.Context, userID string) (*UserData, error) {
|
||||
key := sc.keys.UserKey(userID)
|
||||
var user UserData
|
||||
|
||||
if err := sc.service.Get(ctx, key, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes user data from cache
|
||||
func (sc *SessionCache) DeleteUser(ctx context.Context, userID string) error {
|
||||
key := sc.keys.UserKey(userID)
|
||||
return sc.service.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// InvalidateUserSessions removes all sessions for a user
|
||||
func (sc *SessionCache) InvalidateUserSessions(ctx context.Context, userID string) error {
|
||||
pattern := sc.keys.Build(PrefixSession, "*")
|
||||
keys, err := sc.service.Keys(ctx, pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toDelete := make([]string, 0)
|
||||
for _, key := range keys {
|
||||
var session SessionData
|
||||
if err := sc.service.Get(ctx, key, &session); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if session.UserID == userID {
|
||||
toDelete = append(toDelete, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toDelete) > 0 {
|
||||
return sc.service.Delete(ctx, toDelete...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RateLimitCheck checks if an action is rate limited
|
||||
func (sc *SessionCache) RateLimitCheck(ctx context.Context, identifier, action string, limit int64, window time.Duration) (bool, error) {
|
||||
key := sc.keys.RateLimitKey(identifier, action)
|
||||
|
||||
count, err := sc.service.Increment(ctx, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Set expiry on first increment
|
||||
if count == 1 {
|
||||
if err := sc.service.Expire(ctx, key, window); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return count <= limit, nil
|
||||
}
|
||||
|
||||
// AcquireLock attempts to acquire a distributed lock
|
||||
func (sc *SessionCache) AcquireLock(ctx context.Context, resource string, ttl time.Duration) (string, bool, error) {
|
||||
lockID := uuid.New().String()
|
||||
key := sc.keys.LockKey(resource)
|
||||
|
||||
acquired, err := sc.service.SetNX(ctx, key, lockID, ttl)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
return lockID, acquired, nil
|
||||
}
|
||||
|
||||
// ReleaseLock releases a distributed lock
|
||||
func (sc *SessionCache) ReleaseLock(ctx context.Context, resource, lockID string) error {
|
||||
key := sc.keys.LockKey(resource)
|
||||
|
||||
// Verify we own the lock before deleting
|
||||
var storedLockID string
|
||||
if err := sc.service.Get(ctx, key, &storedLockID); err != nil {
|
||||
if err == ErrCacheMiss {
|
||||
return nil // Lock already released
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if storedLockID != lockID {
|
||||
return fmt.Errorf("lock owned by different process")
|
||||
}
|
||||
|
||||
return sc.service.Delete(ctx, key)
|
||||
}
|
||||
Reference in New Issue
Block a user