356 lines
10 KiB
Go
356 lines
10 KiB
Go
package cache
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/go-redis/redis/v8"
|
|
"github.com/thearchitectit/guardrail-mcp/internal/config"
|
|
"github.com/thearchitectit/guardrail-mcp/internal/metrics"
|
|
)
|
|
|
|
// Client wraps Redis client with guardrail-specific operations
|
|
type Client struct {
|
|
client *redis.Client
|
|
ttl time.Duration
|
|
}
|
|
|
|
// New creates a new Redis client
|
|
func New(cfg *config.Config) (*Client, error) {
|
|
opts := &redis.Options{
|
|
Addr: cfg.RedisAddr(),
|
|
Password: cfg.RedisPassword,
|
|
DB: cfg.RedisDB,
|
|
PoolSize: cfg.RedisPoolSize,
|
|
MinIdleConns: cfg.RedisMinIdleConns,
|
|
MaxRetries: cfg.RedisMaxRetries,
|
|
ReadTimeout: cfg.RedisReadTimeout,
|
|
WriteTimeout: cfg.RedisReadTimeout,
|
|
}
|
|
|
|
// TLS for production
|
|
if cfg.RedisUseTLS {
|
|
opts.TLSConfig = &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
ServerName: cfg.RedisHost,
|
|
}
|
|
}
|
|
|
|
client := redis.NewClient(opts)
|
|
|
|
// Verify connection
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := client.Ping(ctx).Err(); err != nil {
|
|
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
|
|
}
|
|
|
|
slog.Info("Redis connected", "addr", cfg.RedisAddr())
|
|
|
|
return &Client{
|
|
client: client,
|
|
ttl: 5 * time.Minute,
|
|
}, nil
|
|
}
|
|
|
|
// HealthCheck verifies Redis connectivity
|
|
func (c *Client) HealthCheck(ctx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
return c.client.Ping(ctx).Err()
|
|
}
|
|
|
|
// Close closes the Redis connection
|
|
func (c *Client) Close() error {
|
|
slog.Info("Closing Redis connection")
|
|
return c.client.Close()
|
|
}
|
|
|
|
// Get retrieves a value from cache
|
|
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
|
start := time.Now()
|
|
data, err := c.client.Get(ctx, key).Bytes()
|
|
duration := time.Since(start)
|
|
|
|
if err == redis.Nil {
|
|
metrics.RecordCacheMiss("get")
|
|
metrics.RecordCacheOperation("get", duration)
|
|
return nil, err
|
|
}
|
|
if err != nil {
|
|
metrics.RecordCacheError("get")
|
|
metrics.RecordCacheOperation("get", duration)
|
|
return nil, err
|
|
}
|
|
|
|
metrics.RecordCacheHit("get")
|
|
metrics.RecordCacheOperation("get", duration)
|
|
return data, nil
|
|
}
|
|
|
|
// Set stores a value in cache
|
|
func (c *Client) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
|
|
if ttl == 0 {
|
|
ttl = c.ttl
|
|
}
|
|
|
|
start := time.Now()
|
|
err := c.client.Set(ctx, key, value, ttl).Err()
|
|
duration := time.Since(start)
|
|
|
|
if err != nil {
|
|
metrics.RecordCacheError("set")
|
|
} else {
|
|
metrics.RecordCacheHit("set")
|
|
}
|
|
metrics.RecordCacheOperation("set", duration)
|
|
|
|
return err
|
|
}
|
|
|
|
// Delete removes a key from cache
|
|
func (c *Client) Delete(ctx context.Context, key string) error {
|
|
start := time.Now()
|
|
err := c.client.Del(ctx, key).Err()
|
|
duration := time.Since(start)
|
|
|
|
if err != nil {
|
|
metrics.RecordCacheError("delete")
|
|
}
|
|
metrics.RecordCacheOperation("delete", duration)
|
|
|
|
return err
|
|
}
|
|
|
|
// Cache keys
|
|
const (
|
|
KeyActiveRules = "guardrail:rules:active"
|
|
KeyDocument = "guardrail:doc:%s" // Format with slug
|
|
KeyRule = "guardrail:rule:%s" // Format with rule_id
|
|
KeyProjectContext = "guardrail:project:%s" // Format with slug
|
|
KeyProjectRules = "guardrail:project:%s:rules" // Format with project slug
|
|
KeyIDERules = "guardrail:ide:rules:%s" // Format with project slug or "default"
|
|
KeySearchResults = "guardrail:search:%s" // Format with query hash
|
|
KeySession = "guardrail:session:%s" // Format with token
|
|
)
|
|
|
|
// Default TTL values for different cache types
|
|
const (
|
|
TTLActiveRules = 5 * time.Minute
|
|
TTLProjectRules = 10 * time.Minute
|
|
TTLIDERules = 2 * time.Minute
|
|
TTLDocument = 10 * time.Minute
|
|
TTLSearchResults = 1 * time.Minute
|
|
)
|
|
|
|
// GetActiveRules retrieves cached active rules
|
|
func (c *Client) GetActiveRules(ctx context.Context) ([]byte, error) {
|
|
return c.Get(ctx, KeyActiveRules)
|
|
}
|
|
|
|
// SetActiveRules caches active rules
|
|
func (c *Client) SetActiveRules(ctx context.Context, data []byte, ttl time.Duration) error {
|
|
return c.Set(ctx, KeyActiveRules, data, ttl)
|
|
}
|
|
|
|
// InvalidateOnRuleChange clears rule-related caches
|
|
func (c *Client) InvalidateOnRuleChange(ctx context.Context, ruleID string) error {
|
|
// Use a timeout context to prevent long-running operations
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
pipe := c.client.Pipeline()
|
|
|
|
// Delete specific rule cache
|
|
pipe.Del(ctx, fmt.Sprintf(KeyRule, ruleID))
|
|
|
|
// Delete active rules list
|
|
pipe.Del(ctx, KeyActiveRules)
|
|
|
|
_, err := pipe.Exec(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to invalidate rule cache: %w", err)
|
|
}
|
|
|
|
// Delete search result caches using SCAN instead of KEYS for production safety
|
|
// KEYS is blocking and should not be used in production
|
|
return c.deleteKeysByPattern(ctx, "guardrail:search:*")
|
|
}
|
|
|
|
// InvalidateOnDocumentChange clears doc-related caches
|
|
func (c *Client) InvalidateOnDocumentChange(ctx context.Context, slug string) error {
|
|
// Use a timeout context to prevent long-running operations
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
pipe := c.client.Pipeline()
|
|
|
|
// Delete specific document cache
|
|
pipe.Del(ctx, fmt.Sprintf(KeyDocument, slug))
|
|
|
|
_, err := pipe.Exec(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to invalidate document cache: %w", err)
|
|
}
|
|
|
|
// Delete search result caches using SCAN instead of KEYS for production safety
|
|
return c.deleteKeysByPattern(ctx, "guardrail:search:*")
|
|
}
|
|
|
|
// InvalidateOnProjectChange clears project caches
|
|
func (c *Client) InvalidateOnProjectChange(ctx context.Context, slug string) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
pipe := c.client.Pipeline()
|
|
|
|
// Delete project context and rules caches
|
|
pipe.Del(ctx, fmt.Sprintf(KeyProjectContext, slug))
|
|
pipe.Del(ctx, fmt.Sprintf(KeyProjectRules, slug))
|
|
pipe.Del(ctx, fmt.Sprintf(KeyIDERules, slug))
|
|
|
|
_, err := pipe.Exec(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to invalidate project cache: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetProjectRules retrieves cached project-specific rules
|
|
func (c *Client) GetProjectRules(ctx context.Context, projectSlug string) ([]byte, error) {
|
|
return c.Get(ctx, fmt.Sprintf(KeyProjectRules, projectSlug))
|
|
}
|
|
|
|
// SetProjectRules caches project-specific rules
|
|
func (c *Client) SetProjectRules(ctx context.Context, projectSlug string, data []byte) error {
|
|
return c.Set(ctx, fmt.Sprintf(KeyProjectRules, projectSlug), data, TTLProjectRules)
|
|
}
|
|
|
|
// GetIDERules retrieves cached IDE rules for a project
|
|
func (c *Client) GetIDERules(ctx context.Context, projectSlug string) ([]byte, error) {
|
|
return c.Get(ctx, fmt.Sprintf(KeyIDERules, projectSlug))
|
|
}
|
|
|
|
// SetIDERules caches IDE rules for a project
|
|
func (c *Client) SetIDERules(ctx context.Context, projectSlug string, data []byte) error {
|
|
return c.Set(ctx, fmt.Sprintf(KeyIDERules, projectSlug), data, TTLIDERules)
|
|
}
|
|
|
|
// GetOrSet is a cache-aside helper that retrieves from cache or executes fetchFunc
|
|
// and caches the result. This reduces boilerplate for cache lookups.
|
|
func (c *Client) GetOrSet(ctx context.Context, key string, ttl time.Duration, fetchFunc func() ([]byte, error)) ([]byte, error) {
|
|
// Try cache first
|
|
if data, err := c.Get(ctx, key); err == nil && len(data) > 0 {
|
|
return data, nil
|
|
}
|
|
|
|
// Fetch from source
|
|
data, err := fetchFunc()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Cache the result (ignore cache errors to not fail the request)
|
|
if err := c.Set(ctx, key, data, ttl); err != nil {
|
|
slog.Warn("Failed to cache data", "key", key, "error", err)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// DistributedRateLimiter implements distributed rate limiting
|
|
type DistributedRateLimiter struct {
|
|
redis *redis.Client
|
|
window time.Duration
|
|
}
|
|
|
|
// NewDistributedLimiter creates a new distributed rate limiter
|
|
func (c *Client) NewDistributedLimiter() *DistributedRateLimiter {
|
|
return &DistributedRateLimiter{
|
|
redis: c.client,
|
|
window: time.Minute,
|
|
}
|
|
}
|
|
|
|
// Allow checks if a request is allowed under the rate limit
|
|
func (dl *DistributedRateLimiter) Allow(ctx context.Context, key string, limit int) bool {
|
|
// Sliding window counter in Redis
|
|
now := time.Now().Unix()
|
|
windowKey := fmt.Sprintf("ratelimit:%s:%d", key, now/60)
|
|
|
|
pipe := dl.redis.Pipeline()
|
|
incr := pipe.Incr(ctx, windowKey)
|
|
pipe.Expire(ctx, windowKey, dl.window)
|
|
_, err := pipe.Exec(ctx)
|
|
if err != nil {
|
|
// Fail closed on Redis error - security first
|
|
slog.Error("Rate limiting Redis error, failing closed", "error", err)
|
|
return false
|
|
}
|
|
|
|
return incr.Val() <= int64(limit)
|
|
}
|
|
|
|
// PubSub provides access to Redis Pub/Sub for cache coordination
|
|
func (c *Client) PubSub(ctx context.Context) *redis.PubSub {
|
|
return c.client.Subscribe(ctx, "cache:invalidations")
|
|
}
|
|
|
|
// Publish sends a message to a channel
|
|
func (c *Client) Publish(ctx context.Context, channel string, message interface{}) error {
|
|
return c.client.Publish(ctx, channel, message).Err()
|
|
}
|
|
|
|
// InvalidationMessage represents a cache invalidation event
|
|
type InvalidationMessage struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
// BroadcastInvalidation sends an invalidation message to all instances
|
|
func (c *Client) BroadcastInvalidation(ctx context.Context, msg InvalidationMessage) error {
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.Publish(ctx, "cache:invalidations", data)
|
|
}
|
|
|
|
// deleteKeysByPattern safely deletes keys matching a pattern using SCAN
|
|
// This is a non-blocking alternative to KEYS for production use
|
|
func (c *Client) deleteKeysByPattern(ctx context.Context, pattern string) error {
|
|
var cursor uint64
|
|
var keys []string
|
|
|
|
// Use SCAN to iterate through keys in a non-blocking way
|
|
for {
|
|
var err error
|
|
keys, cursor, err = c.client.Scan(ctx, cursor, pattern, 100).Result()
|
|
if err != nil {
|
|
return fmt.Errorf("scan failed: %w", err)
|
|
}
|
|
|
|
// Delete keys in batch if any found
|
|
if len(keys) > 0 {
|
|
if err := c.client.Del(ctx, keys...).Err(); err != nil {
|
|
slog.Warn("Failed to delete some keys during cache invalidation", "error", err)
|
|
// Continue even if some deletions fail
|
|
}
|
|
}
|
|
|
|
// Exit when cursor returns to 0
|
|
if cursor == 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|