small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:24 +02:00
commit 5c500a72b0
243 changed files with 44176 additions and 0 deletions
+136
View File
@@ -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)
}
+209
View File
@@ -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
}
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+205
View File
@@ -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)
}