Skip to content
Alita Robot
Esc
navigateopen⌘Jpreview
On this page

Caching Architecture

Redis caching implementation and patterns in Alita Robot.

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.

Cache Configuration

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  // unexported
    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)
    SetMarshal(marshaler.New(cacheManager))
    Manager = cacheManager

    return nil
}

:::note[Connection retry] The cache initialization uses exponential backoff (1s, 2s, 4s, 8s, 16s) for Redis connection retries. This handles transient network issues during startup, particularly in containerized environments where Redis may not be immediately available. :::

TTL Values

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

Constant Duration Used For
CacheTTLChatSettings 30 minutes Chat configuration
CacheTTLLanguage 1 hour Language preferences
CacheTTLFilterList 30 minutes Message filters
CacheTTLBlacklist 30 minutes Blacklisted words
CacheTTLGreetings 30 minutes Welcome/goodbye messages
CacheTTLNotesList 30 minutes Saved notes
CacheTTLNotesSettings 30 minutes Notes configuration
CacheTTLWarnSettings 30 minutes Warning configuration
CacheTTLAntiflood 30 minutes Flood protection settings
CacheTTLDisabledCmds 30 minutes Disabled commands list
CacheTTLAntiRaid 30 minutes Anti-raid settings
CacheTTLApprovals 30 minutes Approved users list
CacheTTLCaptchaSettings 30 minutes Captcha verification settings
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
    CacheTTLNotesSettings   = 30 * time.Minute
    CacheTTLWarnSettings    = 30 * time.Minute
    CacheTTLAntiflood       = 30 * time.Minute
    CacheTTLDisabledCmds    = 30 * time.Minute
    CacheTTLAntiRaid        = 30 * time.Minute
    CacheTTLApprovals       = 30 * time.Minute
    CacheTTLCaptchaSettings = 30 * time.Minute
)

:::tip[TTL selection strategy] Choose TTL based on how frequently data changes:

  • Rarely changed (language preferences): 1 hour
  • Occasionally changed (chat settings, filters): 30 minutes
  • Highly dynamic (anonymous admin verification): 20 seconds
  • Never use infinite TTL – always set an upper bound to prevent stale data accumulation. :::

Key Patterns

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

Key Pattern Description
alita:chat_settings:{chatId} Legacy invalidation target only — settings are read via alita:chat:{chatId}
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)
alita:captcha_settings:{chatId} Captcha settings (30 min TTL)
alita:approvals:{chatId} Approved users list (30 min TTL)
alita:antiraid:state:{chatId} Live anti-raid state (TTL covers the requested raid expiry, capped at 24h)
alita:antiraid:joins:{chatId} Anti-raid join tracking (60s counting window)
alita:locks_map:{chatId} Lock status (1 hour TTL, from optimized queries)
alita:user:{userId} User basic info (1 hour TTL, from optimized queries)
alita:chat:{chatId} Chat basic info (30 min TTL, from optimized queries)
alita:antiflood:{chatId} Antiflood settings (30 min TTL, from optimized queries)
alita:channel:{chatId} Channel settings (30 min TTL, from optimized queries)

Anonymous Admin Verification Flow

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, the callback handler retrieves the original message from cache via cache.GetMarshal().Get
  4. The bot verifies the user is an admin and executes the original command
// Store original message for anonymous admin
cache.GetMarshal().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
var originalMsg gotgbot.Message
_, err := cache.GetMarshal().Get(
    cache.Context,
    fmt.Sprintf("alita:anonAdmin:%d:%d", chatId, msgId),
    &originalMsg,
)

:::note[Why 20 seconds?] The anonymous admin verification window is intentionally short. If the admin does not click the verification button within 20 seconds, the cached message expires and the command is silently dropped. This prevents stale command executions and reduces cache memory usage. :::

Key Generator Functions

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)
}

func captchaSettingsCacheKey(chatID int64) string {
    return fmt.Sprintf("alita:captcha_settings:%d", chatID)
}

Stampede Protection

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

    m := cache.GetMarshal()
    if m == nil {
        return loader()  // Cache not initialized
    }

    // Try cache first
    _, err := m.Get(cache.Context, key, &result)
    if err == nil {
        return result, nil  // Cache hit
    }

    // Cache miss - use singleflight with timeout
    resCh := make(chan struct {
        val T
        err error
    }, 1)

    go func() {
        defer error_handling.RecoverFromPanic("cache", "GetFromCacheOrLoad")

        // Only ONE goroutine executes this, others wait
        v, err, shared := cacheGroup.Do(key, func() (interface{}, error) {
            val, err := loader()
            if err != nil {
                return nil, err
            }
            if err := m.Set(cache.Context, key, val, store.WithExpiration(ttl)); err != nil {
                log.Debugf("[Cache] Failed to set cache for key %s: %v", key, err)
            }
            return val, nil
        })

        if shared {
            log.Debugf("[Cache] Shared cache load for key: %s", key)
        }

        if err != nil {
            resCh <- struct {
                val T
                err error
            }{result, err}
            return
        }

        resCh <- struct {
            val T
            err error
        }{v.(T), nil}
    }()

    select {
    case res := <-resCh:
        return res.val, res.err
    case <-time.After(30 * time.Second):
        cacheGroup.Forget(key)  // Cleanup on timeout
        log.Errorf("[Cache] Timeout loading key %s after 30s", key)
        return result, fmt.Errorf("cache load timeout for key %s", key)
    }
}

:::tip[Singleflight explained] Without singleflight, if a cache key expires and 100 concurrent requests need that key, all 100 would hit the database simultaneously. With singleflight, only 1 request executes the database query while the other 99 wait and share the result. This is the primary defense against cache stampede. :::

How Singleflight Works

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

Cache Invalidation

:::caution[The most important rule] Every database write function that modifies cached data MUST call the corresponding cache invalidation function. Missing invalidation is the most common caching bug and causes users to see stale data for up to the TTL duration (30 minutes to 1 hour). :::

When data changes, invalidate the cache:

func deleteCache(key string) {
    m := cache.GetMarshal()
    if m == nil {
        return
    }

    err := m.Delete(cache.Context, key)
    if err != nil {
        log.Debugf("[Cache] Failed to delete cache for key %s: %v", key, err)
    }
}

Example: Updating Chat Settings

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 Cache

Admin lists are cached specially for performance:

type AdminCache struct {
    ChatId   int64
    UserInfo []gotgbot.MergedChatMember
    UserMap  map[int64]gotgbot.MergedChatMember // O(1) lookup map
    Cached   bool
}

// LoadAdminCache fetches and caches admin list (simplified)
func LoadAdminCache(b *gotgbot.Bot, chatID int64) 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
    userMap := make(map[int64]gotgbot.MergedChatMember, len(admins))
    for _, admin := range admins {
        merged := admin.MergeChatMember()
        memberList = append(memberList, merged)
        user := admin.GetUser()
        if user.Id != 0 {
            userMap[user.Id] = merged
        }
    }

    adminCache := AdminCache{
        ChatId:   chatID,
        UserInfo: memberList,
        UserMap:  userMap,
        Cached:   true,
    }

    // Store in Redis via cache.GetMarshal().Set
    cache.GetMarshal().Set(cache.Context, fmt.Sprintf("alita:adminCache:%d", chatID),
        adminCache, store.WithExpiration(30*time.Minute))

    return adminCache
}

:::note[Admin cache robustness] The actual implementation includes additional robustness features: bot admin verification before API calls, retry logic with exponential backoff, background cache storage with panic recovery, and graceful handling of non-admin bots. :::

Admin Cache Lookup

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{}
}

CLEAR_CACHE_ON_STARTUP

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
}

:::caution[FLUSHDB is destructive] CLEAR_CACHE_ON_STARTUP triggers FLUSHDB, which wipes ALL keys in the Redis database. If other applications share the same Redis instance and database number, their data will be destroyed. Always use a dedicated Redis database number for the bot. :::

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

Best Practices

1. Always Invalidate on Updates

:::caution This is the single most important caching rule. Forgetting to invalidate causes stale data bugs that are difficult to diagnose because they only manifest intermittently (depending on TTL timing). :::

// 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!
}

2. Use Appropriate TTLs

// 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

3. Handle Cache Misses Gracefully

:::tip Always return a safe default when the cache and database both fail. Never let a cache miss propagate as a nil pointer to the caller. The pattern below returns a disabled-by-default struct, which is the safest fallback for most settings. :::

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
}

4. Use Consistent Key Patterns

// 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}"

:::note[Key format convention] All keys follow the pattern alita:{domain}:{identifier}. Use underscores within domain names (e.g., chat_settings, user_lang). Use colons as separators between segments. This makes it easy to use Redis KEYS alita:chat_settings:* for debugging. :::

5. Set Timeout on Cache Operations

// 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()
}

:::tip[Why 30-second timeout?] The 30-second timeout on cache operations ensures that if Redis becomes unresponsive, the application degrades gracefully by falling back to direct database queries rather than hanging indefinitely. The cacheGroup.Forget(key) call prevents the singleflight group from holding a stale entry. :::

Cache Monitoring

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)
# 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"

:::tip[Cache operations] Use cache.GetMarshal().Get/Set/Delete for direct cache operations, and prefer GetFromCacheOrLoad() in alita/db/cache/loader.go for DB-backed cached reads with singleflight protection to prevent cache stampedes. :::

Next Steps

Was this page helpful?