Skip to content

Caching Architecture

Alita Robot uses Redis as its caching layer to reduce database load and improve response times. This document explains the caching architecture, patterns, and best practices.

The cache is initialized in alita/utils/cache/cache.go:

package cache
import (
"context"
"github.com/eko/gocache/lib/v4/cache"
"github.com/eko/gocache/lib/v4/marshaler"
redis_store "github.com/eko/gocache/store/redis/v4"
"github.com/redis/go-redis/v9"
)
var (
Context = context.Background()
Marshal *marshaler.Marshaler
Manager *cache.Cache[any]
redisClient *redis.Client
)
func InitCache() error {
// Initialize Redis client
redisClient = redis.NewClient(&redis.Options{
Addr: config.AppConfig.RedisAddress,
Password: config.AppConfig.RedisPassword,
DB: config.AppConfig.RedisDB,
})
// Test connection with retry logic
maxRetries := 5
for attempt := 0; attempt < maxRetries; attempt++ {
if err := redisClient.Ping(Context).Err(); err == nil {
break
}
time.Sleep(time.Duration(1<<attempt) * time.Second) // Exponential backoff
}
// Clear cache on startup if configured
if config.AppConfig.ClearCacheOnStartup {
ClearAllCaches()
}
// Initialize cache manager
redisStore := redis_store.NewRedis(redisClient)
cacheManager := cache.New[any](redisStore)
Marshal = marshaler.New(cacheManager)
Manager = cacheManager
return nil
}

Cache Time-To-Live (TTL) values are defined in alita/db/cache_helpers.go:

ConstantDurationUsed For
CacheTTLChatSettings30 minutesChat configuration
CacheTTLLanguage1 hourLanguage preferences
CacheTTLFilterList30 minutesMessage filters
CacheTTLBlacklist30 minutesBlacklisted words
CacheTTLGreetings30 minutesWelcome/goodbye messages
CacheTTLNotesList30 minutesSaved notes
CacheTTLWarnSettings30 minutesWarning configuration
CacheTTLAntiflood30 minutesFlood protection settings
CacheTTLDisabledCmds30 minutesDisabled commands list
const (
CacheTTLChatSettings = 30 * time.Minute
CacheTTLLanguage = 1 * time.Hour
CacheTTLFilterList = 30 * time.Minute
CacheTTLBlacklist = 30 * time.Minute
CacheTTLGreetings = 30 * time.Minute
CacheTTLNotesList = 30 * time.Minute
CacheTTLWarnSettings = 30 * time.Minute
CacheTTLAntiflood = 30 * time.Minute
CacheTTLDisabledCmds = 30 * time.Minute
)

All cache keys use the alita: prefix for namespace isolation:

Key PatternDescription
alita:chat_settings:{chatId}Chat settings object
alita:user_lang:{userId}User language preference
alita:chat_lang:{chatId}Chat language preference
alita:filter_list:{chatId}List of filters for chat
alita:blacklist:{chatId}Blacklist settings
alita:warn_settings:{chatId}Warning settings
alita:disabled_cmds:{chatId}Disabled commands
alita:anonAdmin:{chatId}:{msgId}Anonymous admin verification (20s TTL)
alita:adminCache:{chatId}Cached admin list for a chat (30min TTL)

When an anonymous admin uses a command, the bot:

  1. Stores the original message in cache with key alita:anonAdmin:{chatId}:{msgId}
  2. Sends a verification button to the chat
  3. When clicked, getAnonAdminCache() retrieves the original message
  4. The bot verifies the user is an admin and executes the original command
// Store original message for anonymous admin
cache.Marshal.Set(
cache.Context,
fmt.Sprintf("alita:anonAdmin:%d:%d", chatId, msgId),
originalMessage,
store.WithExpiration(20*time.Second), // Short TTL - button expires quickly
)
// Retrieve when verification button is clicked
func getAnonAdminCache(chatId, msgId int64) (any, error) {
return cache.Marshal.Get(
cache.Context,
fmt.Sprintf("alita:anonAdmin:%d:%d", chatId, msgId),
new(gotgbot.Message),
)
}
func chatSettingsCacheKey(chatID int64) string {
return fmt.Sprintf("alita:chat_settings:%d", chatID)
}
func userLanguageCacheKey(userID int64) string {
return fmt.Sprintf("alita:user_lang:%d", userID)
}
func chatLanguageCacheKey(chatID int64) string {
return fmt.Sprintf("alita:chat_lang:%d", chatID)
}
func filterListCacheKey(chatID int64) string {
return fmt.Sprintf("alita:filter_list:%d", chatID)
}
func blacklistCacheKey(chatID int64) string {
return fmt.Sprintf("alita:blacklist:%d", chatID)
}
func warnSettingsCacheKey(chatID int64) string {
return fmt.Sprintf("alita:warn_settings:%d", chatID)
}
func disabledCommandsCacheKey(chatID int64) string {
return fmt.Sprintf("alita:disabled_cmds:%d", chatID)
}

The cache uses singleflight to prevent cache stampede (thundering herd problem):

import "golang.org/x/sync/singleflight"
var cacheGroup singleflight.Group
func getFromCacheOrLoad[T any](key string, ttl time.Duration, loader func() (T, error)) (T, error) {
var result T
if cache.Marshal == nil {
return loader() // Cache not initialized
}
// Try cache first
_, err := cache.Marshal.Get(cache.Context, key, &result)
if err == nil {
return result, nil // Cache hit
}
// Cache miss - use singleflight with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resultChan := make(chan sfResult, 1)
go func() {
defer error_handling.RecoverFromPanic("getFromCacheOrLoad", "cache_helpers")
// Only ONE goroutine executes this, others wait
v, err, _ := cacheGroup.Do(key, func() (any, error) {
// Load from database
data, loadErr := loader()
if loadErr != nil {
return data, loadErr
}
// Store in cache
cache.Marshal.Set(cache.Context, key, data, store.WithExpiration(ttl))
return data, nil
})
resultChan <- sfResult{value: v, err: err}
}()
select {
case res := <-resultChan:
if typedResult, ok := res.value.(T); ok {
return typedResult, res.err
}
return result, res.err
case <-ctx.Done():
cacheGroup.Forget(key) // Cleanup on timeout
return result, fmt.Errorf("cache load timeout for key %s", key)
}
}
Request 1 ──┐
Request 2 ──┼──> singleflight.Do(key) ──> loader() ──> result
Request 3 ──┘ │
All requests get same result <┘

Without singleflight, if cache expires and 100 requests arrive simultaneously:

  • Bad: 100 database queries
  • Good: 1 database query, 99 requests wait and share result

When data changes, invalidate the cache:

func deleteCache(key string) {
if cache.Marshal == nil {
return
}
err := cache.Marshal.Delete(cache.Context, key)
if err != nil {
log.Debugf("[Cache] Failed to delete cache for key %s: %v", key, err)
}
}
func SetChatSettings(chatID int64, settings ChatSettings) error {
// Update database
tx := db.Session(&gorm.Session{}).Where("chat_id = ?", chatID).
Assign(settings).FirstOrCreate(&settings)
if tx.Error != nil {
return tx.Error
}
// Invalidate cache - IMPORTANT!
deleteCache(chatSettingsCacheKey(chatID))
return nil
}

Admin lists are cached specially for performance:

type AdminCache struct {
ChatId int64
UserInfo []gotgbot.MergedChatMember
Cached bool
}
// LoadAdminCache fetches and caches admin list
func LoadAdminCache(b *gotgbot.Bot, chatID int64) AdminCache {
// Check if already cached
found, adminCache := GetAdminCacheList(chatID)
if found && adminCache.Cached {
return adminCache
}
// Fetch from Telegram API
admins, err := b.GetChatAdministrators(chatID, nil)
if err != nil {
return AdminCache{ChatId: chatID, Cached: false}
}
// Build cache
var memberList []gotgbot.MergedChatMember
for _, admin := range admins {
memberList = append(memberList, admin.MergeChatMember())
}
cache := AdminCache{
ChatId: chatID,
UserInfo: memberList,
Cached: true,
}
// Store in Redis
SetAdminCacheList(chatID, cache)
return cache
}
func GetAdminCacheUser(chatID int64, userID int64) (bool, gotgbot.MergedChatMember) {
found, adminCache := GetAdminCacheList(chatID)
if !found || !adminCache.Cached {
return false, gotgbot.MergedChatMember{}
}
for _, member := range adminCache.UserInfo {
if member.User.Id == userID {
return true, member
}
}
return false, gotgbot.MergedChatMember{}
}

The CLEAR_CACHE_ON_STARTUP environment variable controls cache clearing:

if config.AppConfig.ClearCacheOnStartup {
ClearAllCaches()
}
func ClearAllCaches() error {
if redisClient == nil {
return fmt.Errorf("redis client not initialized")
}
log.Info("[Cache] Clearing all caches using FLUSHDB...")
// FLUSHDB clears all keys in current database
if err := redisClient.FlushDB(Context).Err(); err != nil {
return fmt.Errorf("failed to flush database: %w", err)
}
log.Info("[Cache] Successfully cleared all cache entries")
return nil
}

When to enable:

  • After schema changes
  • When debugging cache issues
  • After significant code changes affecting cached data

When to disable (production):

  • Normal operations
  • To preserve cache across restarts
  • To reduce database load during deployment
// BAD - Cache becomes stale
func UpdateSettings(chatID int64, settings Settings) {
db.Save(&settings)
// Missing cache invalidation!
}
// GOOD - Cache stays consistent
func UpdateSettings(chatID int64, settings Settings) {
db.Save(&settings)
deleteCache(settingsCacheKey(chatID)) // Invalidate!
}
// Frequently accessed, rarely changed -> longer TTL
CacheTTLLanguage = 1 * time.Hour
// Frequently changed -> shorter TTL
CacheTTLAntiflood = 30 * time.Minute
// Highly dynamic -> very short or no cache
anonChatMapExpiration = 20 * time.Second
func GetSettings(chatID int64) *Settings {
result, err := getFromCacheOrLoad(
settingsCacheKey(chatID),
CacheTTLSettings,
func() (*Settings, error) {
var settings Settings
tx := db.Where("chat_id = ?", chatID).First(&settings)
if tx.Error != nil {
// Return default, not error
return &Settings{ChatID: chatID, Enabled: false}, nil
}
return &settings, nil
},
)
if err != nil {
// Return safe default on cache error
return &Settings{ChatID: chatID, Enabled: false}
}
return result
}
// GOOD - Consistent prefix and format
"alita:chat_settings:{chatId}"
"alita:user_lang:{userId}"
"alita:filter_list:{chatId}"
// BAD - Inconsistent patterns
"settings-{chatId}"
"user:{userId}:language"
"chatFilters{chatId}"
// Prevent hanging on Redis issues
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
select {
case result := <-resultChan:
return result
case <-ctx.Done():
cacheGroup.Forget(key) // Cleanup
return defaultValue, ctx.Err()
}

Monitor cache performance via:

  1. Logs: Cache hits/misses logged at Debug level
  2. Redis CLI: redis-cli INFO stats for hit rates
  3. Metrics: Prometheus metrics (if enabled)
Terminal window
# Check cache key count
redis-cli DBSIZE
# View all Alita keys
redis-cli KEYS "alita:*"
# Check specific key TTL
redis-cli TTL "alita:chat_settings:123456789"
# Memory usage
redis-cli MEMORY USAGE "alita:chat_settings:123456789"