| package service |
| |
| import ( |
| "context" |
| "crypto/md5" |
| "encoding/hex" |
| "encoding/json" |
| "fmt" |
| "strings" |
| "time" |
| |
| "github.com/korap/korap-mcp/config" |
| "github.com/korap/korap-mcp/logger" |
| "github.com/maypok86/otter" |
| "github.com/rs/zerolog" |
| ) |
| |
| // CacheEntry represents a cached API response |
| type CacheEntry struct { |
| Data []byte `json:"data"` |
| Timestamp time.Time `json:"timestamp"` |
| TTL time.Duration `json:"ttl"` |
| } |
| |
| // IsExpired checks if the cache entry has expired |
| func (ce *CacheEntry) IsExpired() bool { |
| return time.Since(ce.Timestamp) > ce.TTL |
| } |
| |
| // Cache represents the response cache system |
| type Cache struct { |
| cache *otter.Cache[string, *CacheEntry] |
| logger zerolog.Logger |
| config *config.CacheConfig |
| } |
| |
| // NewCache creates a new cache instance from config.CacheConfig |
| func NewCache(cacheConfig *config.CacheConfig) (*Cache, error) { |
| // Create default logging config for cache |
| logConfig := &config.LoggingConfig{ |
| Level: "info", |
| Format: "text", |
| } |
| |
| if !cacheConfig.Enabled { |
| return &Cache{ |
| cache: nil, |
| logger: logger.GetLogger(logConfig), |
| config: cacheConfig, |
| }, nil |
| } |
| |
| // Create otter cache with specified capacity |
| cache, err := otter.MustBuilder[string, *CacheEntry](cacheConfig.MaxSize). |
| CollectStats(). |
| WithTTL(cacheConfig.GetDefaultTTL()). |
| Build() |
| if err != nil { |
| return nil, fmt.Errorf("failed to create cache: %w", err) |
| } |
| |
| return &Cache{ |
| cache: &cache, |
| logger: logger.GetLogger(logConfig), |
| config: cacheConfig, |
| }, nil |
| } |
| |
| // generateCacheKey creates a unique cache key for a request |
| func (c *Cache) generateCacheKey(method, endpoint string, params map[string]any) string { |
| // Create a deterministic key by combining method, endpoint, and parameters |
| var keyParts []string |
| keyParts = append(keyParts, method, endpoint) |
| |
| // Add sorted parameters to ensure deterministic cache keys |
| // Note: json.Marshal automatically sorts map keys lexicographically, |
| // providing deterministic JSON output regardless of map iteration order |
| if params != nil { |
| paramsJSON, _ := json.Marshal(params) |
| keyParts = append(keyParts, string(paramsJSON)) |
| } |
| |
| key := strings.Join(keyParts, "|") |
| |
| // Hash the key to keep it reasonable length and provide privacy |
| hash := md5.Sum([]byte(key)) |
| return hex.EncodeToString(hash[:]) |
| } |
| |
| // Get retrieves a cached response |
| func (c *Cache) Get(ctx context.Context, key string) ([]byte, bool) { |
| if !c.config.Enabled || c.cache == nil { |
| return nil, false |
| } |
| |
| entry, found := (*c.cache).Get(key) |
| if !found { |
| c.logger.Debug().Str("key", key).Msg("Cache miss") |
| return nil, false |
| } |
| |
| // Check if entry has expired |
| if entry.IsExpired() { |
| c.logger.Debug().Str("key", key).Msg("Cache entry expired") |
| (*c.cache).Delete(key) |
| return nil, false |
| } |
| |
| c.logger.Debug().Str("key", key).Msg("Cache hit") |
| return entry.Data, true |
| } |
| |
| // Set stores a response in the cache |
| func (c *Cache) Set(ctx context.Context, key string, data []byte, ttl time.Duration) { |
| if !c.config.Enabled || c.cache == nil { |
| return |
| } |
| |
| entry := &CacheEntry{ |
| Data: data, |
| Timestamp: time.Now(), |
| TTL: ttl, |
| } |
| |
| (*c.cache).Set(key, entry) |
| c.logger.Debug().Str("key", key).Dur("ttl", ttl).Msg("Cache entry stored") |
| } |
| |
| // Delete removes an entry from the cache |
| func (c *Cache) Delete(ctx context.Context, key string) { |
| if !c.config.Enabled || c.cache == nil { |
| return |
| } |
| |
| (*c.cache).Delete(key) |
| c.logger.Debug().Str("key", key).Msg("Cache entry deleted") |
| } |
| |
| // Clear removes all entries from the cache |
| func (c *Cache) Clear() { |
| if !c.config.Enabled || c.cache == nil { |
| return |
| } |
| |
| (*c.cache).Clear() |
| c.logger.Debug().Msg("Cache cleared") |
| } |
| |
| // Stats returns cache statistics |
| func (c *Cache) Stats() map[string]interface{} { |
| if !c.config.Enabled || c.cache == nil { |
| return map[string]interface{}{ |
| "enabled": false, |
| } |
| } |
| |
| stats := (*c.cache).Stats() |
| return map[string]interface{}{ |
| "enabled": true, |
| "size": (*c.cache).Size(), |
| "hits": stats.Hits(), |
| "misses": stats.Misses(), |
| "hit_ratio": stats.Ratio(), |
| "evictions": stats.EvictedCount(), |
| "max_size": c.config.MaxSize, |
| "default_ttl": c.config.DefaultTTL, |
| "search_ttl": c.config.SearchTTL, |
| "metadata_ttl": c.config.MetadataTTL, |
| } |
| } |
| |
| // GetTTLForEndpoint returns the appropriate TTL for a given endpoint |
| func (c *Cache) GetTTLForEndpoint(endpoint string) time.Duration { |
| endpoint = strings.ToLower(endpoint) |
| |
| // Search endpoints get shorter TTL |
| if strings.Contains(endpoint, "search") || strings.Contains(endpoint, "query") { |
| return c.config.GetSearchTTL() |
| } |
| |
| // Metadata and corpus endpoints get longer TTL |
| if strings.Contains(endpoint, "corpus") || strings.Contains(endpoint, "metadata") || |
| strings.Contains(endpoint, "statistics") || strings.Contains(endpoint, "info") { |
| return c.config.GetMetadataTTL() |
| } |
| |
| // Default TTL for other endpoints |
| return c.config.GetDefaultTTL() |
| } |
| |
| // Close closes the cache and cleans up resources |
| func (c *Cache) Close() error { |
| if c.cache != nil { |
| (*c.cache).Clear() |
| (*c.cache).Close() |
| } |
| return nil |
| } |