2026-03-30 00:31:49 +03:00
|
|
|
package core
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"fmt"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type CacheEntry struct {
|
|
|
|
|
Data []byte
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
ExpiresAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ResponseCache is a bounded in-memory TTL cache for dedicated endpoint responses.
|
|
|
|
|
type ResponseCache struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
entries map[string]CacheEntry
|
|
|
|
|
ttl time.Duration
|
|
|
|
|
maxSize int
|
|
|
|
|
hits int
|
|
|
|
|
misses int
|
|
|
|
|
bypasses int
|
|
|
|
|
evictions int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewResponseCache(ttl time.Duration, maxSize int) *ResponseCache {
|
|
|
|
|
return &ResponseCache{
|
|
|
|
|
entries: make(map[string]CacheEntry),
|
|
|
|
|
ttl: ttl,
|
|
|
|
|
maxSize: maxSize,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func BuildCacheKey(engine string, action string, q Query) string {
|
|
|
|
|
raw := fmt.Sprintf(
|
2026-04-01 17:38:13 +03:00
|
|
|
"%s|%s|%s|%s|%s|%s|%s|%d|%d|%t|%t|%s",
|
2026-03-30 00:31:49 +03:00
|
|
|
engine,
|
|
|
|
|
action,
|
|
|
|
|
q.Text,
|
|
|
|
|
q.LangCode,
|
|
|
|
|
q.DateInterval,
|
|
|
|
|
q.Filetype,
|
|
|
|
|
q.Site,
|
|
|
|
|
q.Limit,
|
|
|
|
|
q.Start,
|
|
|
|
|
q.Filter,
|
|
|
|
|
q.Answers,
|
2026-04-01 17:38:13 +03:00
|
|
|
q.ProxyOverride,
|
2026-03-30 00:31:49 +03:00
|
|
|
)
|
|
|
|
|
hash := sha256.Sum256([]byte(raw))
|
|
|
|
|
return hex.EncodeToString(hash[:])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *ResponseCache) Get(key string) ([]byte, bool) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
c.pruneExpiredLocked(time.Now())
|
|
|
|
|
|
|
|
|
|
entry, ok := c.entries[key]
|
|
|
|
|
if !ok {
|
|
|
|
|
c.misses++
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.hits++
|
|
|
|
|
return entry.Data, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *ResponseCache) Set(key string, data []byte) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
c.pruneExpiredLocked(now)
|
|
|
|
|
|
|
|
|
|
if _, exists := c.entries[key]; !exists && len(c.entries) >= c.maxSize {
|
|
|
|
|
c.evictOldestLocked()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.entries[key] = CacheEntry{
|
|
|
|
|
Data: data,
|
|
|
|
|
CreatedAt: now,
|
|
|
|
|
ExpiresAt: now.Add(c.ttl),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *ResponseCache) RecordBypass() {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
c.bypasses++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *ResponseCache) Stats() map[string]interface{} {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
c.pruneExpiredLocked(time.Now())
|
|
|
|
|
|
|
|
|
|
return map[string]interface{}{
|
|
|
|
|
"status": true,
|
|
|
|
|
"entries": len(c.entries),
|
|
|
|
|
"hits": c.hits,
|
|
|
|
|
"misses": c.misses,
|
|
|
|
|
"bypasses": c.bypasses,
|
|
|
|
|
"evictions": c.evictions,
|
|
|
|
|
"ttl_seconds": int(c.ttl / time.Second),
|
|
|
|
|
"max_size": c.maxSize,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *ResponseCache) pruneExpiredLocked(now time.Time) {
|
|
|
|
|
for key, entry := range c.entries {
|
|
|
|
|
if !now.Before(entry.ExpiresAt) {
|
|
|
|
|
delete(c.entries, key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *ResponseCache) evictOldestLocked() {
|
|
|
|
|
var (
|
|
|
|
|
oldestKey string
|
|
|
|
|
oldestCreated time.Time
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for key, entry := range c.entries {
|
|
|
|
|
if oldestKey == "" || entry.CreatedAt.Before(oldestCreated) {
|
|
|
|
|
oldestKey = key
|
|
|
|
|
oldestCreated = entry.CreatedAt
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if oldestKey != "" {
|
|
|
|
|
delete(c.entries, oldestKey)
|
|
|
|
|
c.evictions++
|
|
|
|
|
}
|
|
|
|
|
}
|