package dashboard import ( "context" "encoding/json" "fmt" "sync" "time" "tm/pkg/logger" "tm/pkg/redis" goredis "github.com/redis/go-redis/v9" "golang.org/x/sync/singleflight" ) const ( // widgetCacheTTL is the fresh window before a background reload is triggered. widgetCacheTTL = 60 * time.Second // widgetStaleGraceTTL is how long stale entries may be served while reloading. // Dashboard widgets have no write-side invalidation; expect up to this lag after // data changes (e.g. a new tender may not appear in cached widgets immediately). widgetStaleGraceTTL = 5 * time.Minute widgetReloadTimeout = 2 * time.Minute ) type widgetLoader[T any] func(ctx context.Context) (T, error) type widgetCache[T any] struct { mu sync.Mutex entries map[string]cacheEntry[T] group singleflight.Group redis redis.Client logger logger.Logger keyPrefix string ttl time.Duration staleGrace time.Duration } func newWidgetCache[T any](redisClient redis.Client, log logger.Logger, keyPrefix string) *widgetCache[T] { return &widgetCache[T]{ entries: make(map[string]cacheEntry[T]), redis: redisClient, logger: log, keyPrefix: keyPrefix, ttl: widgetCacheTTL, staleGrace: widgetStaleGraceTTL, } } func (c *widgetCache[T]) Get(ctx context.Context, key string, load widgetLoader[T]) (T, error) { var zero T // Returned values are shared across concurrent callers via singleflight and stale // hits. Callers must treat them as read-only through JSON serialization. if value, ok := c.fresh(key); ok { return value, nil } if value, ok := c.stale(key); ok { go c.Reload(key, load) return value, nil } if value, ok := c.fromRedis(ctx, key); ok { c.store(key, value) go c.Reload(key, load) return value, nil } out, err, _ := c.group.Do(key, func() (interface{}, error) { if value, ok := c.fresh(key); ok { return value, nil } result, err := load(context.WithoutCancel(ctx)) if err != nil { return zero, err } c.store(key, result) c.storeRedis(context.Background(), key, result) return result, nil }) if err != nil { return zero, err } return out.(T), nil } func (c *widgetCache[T]) Reload(key string, load widgetLoader[T]) { ctx, cancel := context.WithTimeout(context.Background(), widgetReloadTimeout) defer cancel() _, _, _ = c.group.Do("reload:"+key, func() (interface{}, error) { result, err := load(ctx) if err != nil { if c.logger != nil { c.logger.Warn("Failed to refresh dashboard widget cache", map[string]interface{}{ "widget": c.keyPrefix, "key": key, "error": err.Error(), }) } return nil, err } c.store(key, result) c.storeRedis(context.Background(), key, result) return result, nil }) } func (c *widgetCache[T]) WarmFromRedis(ctx context.Context, key string) bool { value, ok := c.fromRedis(ctx, key) if !ok { return false } c.store(key, value) return true } func (c *widgetCache[T]) fresh(key string) (T, bool) { var zero T now := time.Now() c.mu.Lock() defer c.mu.Unlock() entry, ok := c.entries[key] if !ok || now.After(entry.expiresAt) { return zero, false } return entry.value, true } func (c *widgetCache[T]) stale(key string) (T, bool) { var zero T now := time.Now() c.mu.Lock() defer c.mu.Unlock() entry, ok := c.entries[key] if !ok || now.After(entry.staleUntil) { if ok { delete(c.entries, key) } return zero, false } return entry.value, true } func (c *widgetCache[T]) store(key string, value T) { now := time.Now() c.mu.Lock() defer c.mu.Unlock() c.entries[key] = cacheEntry[T]{ expiresAt: now.Add(c.ttl), staleUntil: now.Add(c.ttl + c.staleGrace), value: value, } } func (c *widgetCache[T]) redisKey(key string) string { return fmt.Sprintf("dashboard:%s:%s", c.keyPrefix, key) } func (c *widgetCache[T]) fromRedis(ctx context.Context, key string) (T, bool) { var zero T if c.redis == nil { return zero, false } raw, err := c.redis.Get(ctx, c.redisKey(key)) if err != nil { if err != goredis.Nil && c.logger != nil { c.logger.Warn("Failed to read dashboard widget cache from Redis", map[string]interface{}{ "widget": c.keyPrefix, "key": key, "error": err.Error(), }) } return zero, false } var value T if err := json.Unmarshal([]byte(raw), &value); err != nil { if c.logger != nil { c.logger.Warn("Failed to decode dashboard widget cache from Redis", map[string]interface{}{ "widget": c.keyPrefix, "key": key, "error": err.Error(), }) } _ = c.redis.Del(ctx, c.redisKey(key)) return zero, false } return value, true } func (c *widgetCache[T]) storeRedis(ctx context.Context, key string, value T) { if c.redis == nil { return } encoded, err := json.Marshal(value) if err != nil { if c.logger != nil { c.logger.Warn("Failed to encode dashboard widget cache for Redis", map[string]interface{}{ "widget": c.keyPrefix, "key": key, "error": err.Error(), }) } return } ttl := c.ttl + c.staleGrace if err := c.redis.Set(ctx, c.redisKey(key), string(encoded), ttl); err != nil && c.logger != nil { c.logger.Warn("Failed to store dashboard widget cache in Redis", map[string]interface{}{ "widget": c.keyPrefix, "key": key, "error": err.Error(), }) } }