package services import ( "context" "encoding/json" "fmt" "hash/fnv" "sync" "time" "github.com/redis/go-redis/v9" "github.com/rs/zerolog/log" "github.com/treytartt/honeydue-api/internal/config" "github.com/treytartt/honeydue-api/internal/i18n" ) // CacheService provides Redis caching functionality type CacheService struct { client *redis.Client } var ( cacheInstance *CacheService cacheOnce sync.Once ) // NewCacheService creates a new cache service (thread-safe via sync.Once) func NewCacheService(cfg *config.RedisConfig) (*CacheService, error) { var initErr error cacheOnce.Do(func() { opt, err := redis.ParseURL(cfg.URL) if err != nil { initErr = fmt.Errorf("failed to parse Redis URL: %w", err) return } if cfg.Password != "" { opt.Password = cfg.Password } if cfg.DB != 0 { opt.DB = cfg.DB } client := redis.NewClient(opt) // Test connection ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := client.Ping(ctx).Err(); err != nil { initErr = fmt.Errorf("failed to connect to Redis: %w", err) // NOTE: Don't reassign `cacheOnce = sync.Once{}` here. Mutating the // Once from within its own Do() callback fatals with "unlock of // unlocked mutex" because Do is holding the inner lock while we // zero it. main.go handles the error (caching disabled, keep running); // a pod restart is the right "retry" path for a transient Redis // outage, not in-process. return } // S-14: Mask credentials in Redis URL before logging log.Info(). Str("url", config.MaskURLCredentials(cfg.URL)). Int("db", opt.DB). Msg("Connected to Redis") cacheInstance = &CacheService{client: client} }) if initErr != nil { return nil, initErr } return cacheInstance, nil } // GetCache returns the cache service instance func GetCache() *CacheService { return cacheInstance } // Client returns the underlying Redis client func (c *CacheService) Client() *redis.Client { return c.client } // Set stores a value with expiration func (c *CacheService) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { data, err := json.Marshal(value) if err != nil { return fmt.Errorf("failed to marshal value: %w", err) } return c.client.Set(ctx, key, data, expiration).Err() } // Get retrieves a value by key func (c *CacheService) Get(ctx context.Context, key string, dest interface{}) error { data, err := c.client.Get(ctx, key).Bytes() if err != nil { return err } return json.Unmarshal(data, dest) } // GetString retrieves a string value by key func (c *CacheService) GetString(ctx context.Context, key string) (string, error) { return c.client.Get(ctx, key).Result() } // SetString stores a string value with expiration func (c *CacheService) SetString(ctx context.Context, key string, value string, expiration time.Duration) error { return c.client.Set(ctx, key, value, expiration).Err() } // Delete removes a key func (c *CacheService) Delete(ctx context.Context, keys ...string) error { return c.client.Del(ctx, keys...).Err() } // Exists checks if a key exists func (c *CacheService) Exists(ctx context.Context, keys ...string) (int64, error) { return c.client.Exists(ctx, keys...).Result() } // Close closes the Redis connection func (c *CacheService) Close() error { if c.client != nil { return c.client.Close() } return nil } // Static data cache helpers const ( StaticDataKey = "static_data" StaticDataTTL = 1 * time.Hour ) // CacheStaticData caches static lookup data func (c *CacheService) CacheStaticData(ctx context.Context, data interface{}) error { return c.Set(ctx, StaticDataKey, data, StaticDataTTL) } // GetCachedStaticData retrieves cached static data func (c *CacheService) GetCachedStaticData(ctx context.Context, dest interface{}) error { return c.Get(ctx, StaticDataKey, dest) } // InvalidateStaticData removes cached static data func (c *CacheService) InvalidateStaticData(ctx context.Context) error { return c.Delete(ctx, StaticDataKey) } // Lookup data cache helpers - each lookup type gets its own key const ( LookupKeyPrefix = "lookup:" LookupCategoriesKey = LookupKeyPrefix + "categories" LookupPrioritiesKey = LookupKeyPrefix + "priorities" LookupFrequenciesKey = LookupKeyPrefix + "frequencies" LookupResidenceTypesKey = LookupKeyPrefix + "residence_types" LookupSpecialtiesKey = LookupKeyPrefix + "specialties" LookupTaskTemplatesKey = LookupKeyPrefix + "task_templates" LookupDataTTL = 24 * time.Hour // Lookup data rarely changes ) // CacheLookupData caches data for a specific lookup type func (c *CacheService) CacheLookupData(ctx context.Context, key string, data interface{}) error { return c.Set(ctx, key, data, LookupDataTTL) } // GetCachedLookupData retrieves cached lookup data for a specific key func (c *CacheService) GetCachedLookupData(ctx context.Context, key string, dest interface{}) error { return c.Get(ctx, key, dest) } // InvalidateLookupData removes cached data for a specific lookup type func (c *CacheService) InvalidateLookupData(ctx context.Context, key string) error { return c.Delete(ctx, key) } // InvalidateAllLookups removes all cached lookup data func (c *CacheService) InvalidateAllLookups(ctx context.Context) error { keys := []string{ LookupCategoriesKey, LookupPrioritiesKey, LookupFrequenciesKey, LookupResidenceTypesKey, LookupSpecialtiesKey, LookupTaskTemplatesKey, StaticDataKey, // Also invalidate the combined static data } // Per-locale seeded-data + ETag keys. for _, lang := range i18n.SupportedLanguages { keys = append(keys, seededDataKey(lang), seededDataETagKey(lang)) } return c.Delete(ctx, keys...) } // CacheCategories caches task categories func (c *CacheService) CacheCategories(ctx context.Context, data interface{}) error { return c.CacheLookupData(ctx, LookupCategoriesKey, data) } // GetCachedCategories retrieves cached task categories func (c *CacheService) GetCachedCategories(ctx context.Context, dest interface{}) error { return c.GetCachedLookupData(ctx, LookupCategoriesKey, dest) } // InvalidateCategories removes cached task categories func (c *CacheService) InvalidateCategories(ctx context.Context) error { // Invalidate both specific key and combined static data return c.Delete(ctx, LookupCategoriesKey, StaticDataKey) } // CachePriorities caches task priorities func (c *CacheService) CachePriorities(ctx context.Context, data interface{}) error { return c.CacheLookupData(ctx, LookupPrioritiesKey, data) } // GetCachedPriorities retrieves cached task priorities func (c *CacheService) GetCachedPriorities(ctx context.Context, dest interface{}) error { return c.GetCachedLookupData(ctx, LookupPrioritiesKey, dest) } // InvalidatePriorities removes cached task priorities func (c *CacheService) InvalidatePriorities(ctx context.Context) error { return c.Delete(ctx, LookupPrioritiesKey, StaticDataKey) } // CacheFrequencies caches task frequencies func (c *CacheService) CacheFrequencies(ctx context.Context, data interface{}) error { return c.CacheLookupData(ctx, LookupFrequenciesKey, data) } // GetCachedFrequencies retrieves cached task frequencies func (c *CacheService) GetCachedFrequencies(ctx context.Context, dest interface{}) error { return c.GetCachedLookupData(ctx, LookupFrequenciesKey, dest) } // InvalidateFrequencies removes cached task frequencies func (c *CacheService) InvalidateFrequencies(ctx context.Context) error { return c.Delete(ctx, LookupFrequenciesKey, StaticDataKey) } // CacheResidenceTypes caches residence types func (c *CacheService) CacheResidenceTypes(ctx context.Context, data interface{}) error { return c.CacheLookupData(ctx, LookupResidenceTypesKey, data) } // GetCachedResidenceTypes retrieves cached residence types func (c *CacheService) GetCachedResidenceTypes(ctx context.Context, dest interface{}) error { return c.GetCachedLookupData(ctx, LookupResidenceTypesKey, dest) } // InvalidateResidenceTypes removes cached residence types func (c *CacheService) InvalidateResidenceTypes(ctx context.Context) error { return c.Delete(ctx, LookupResidenceTypesKey, StaticDataKey) } // CacheSpecialties caches contractor specialties func (c *CacheService) CacheSpecialties(ctx context.Context, data interface{}) error { return c.CacheLookupData(ctx, LookupSpecialtiesKey, data) } // GetCachedSpecialties retrieves cached contractor specialties func (c *CacheService) GetCachedSpecialties(ctx context.Context, dest interface{}) error { return c.GetCachedLookupData(ctx, LookupSpecialtiesKey, dest) } // InvalidateSpecialties removes cached contractor specialties func (c *CacheService) InvalidateSpecialties(ctx context.Context) error { return c.Delete(ctx, LookupSpecialtiesKey, StaticDataKey) } // CacheTaskTemplates caches task templates func (c *CacheService) CacheTaskTemplates(ctx context.Context, data interface{}) error { return c.CacheLookupData(ctx, LookupTaskTemplatesKey, data) } // GetCachedTaskTemplates retrieves cached task templates func (c *CacheService) GetCachedTaskTemplates(ctx context.Context, dest interface{}) error { return c.GetCachedLookupData(ctx, LookupTaskTemplatesKey, dest) } // InvalidateTaskTemplates removes cached task templates func (c *CacheService) InvalidateTaskTemplates(ctx context.Context) error { return c.Delete(ctx, LookupTaskTemplatesKey, StaticDataKey) } // Unified seeded data cache helpers. // // The seeded-data payload is localized (lookup display_name + home-profile // option labels), so the cache and ETag are namespaced per locale. Mixing // locales under one key would let the first request poison every other // language and make the ETag meaningless across locales. const ( seededDataPrefix = "seeded_data:" SeededDataTTL = 24 * time.Hour ) func seededDataKey(locale string) string { return seededDataPrefix + locale } func seededDataETagKey(locale string) string { return seededDataPrefix + locale + ":etag" } // CacheSeededData caches the unified seeded data for a locale and generates an // ETag. The locale is folded into the ETag so a client switching languages // always re-fetches rather than getting a stale 304. func (c *CacheService) CacheSeededData(ctx context.Context, locale string, data interface{}) (string, error) { jsonData, err := json.Marshal(data) if err != nil { return "", fmt.Errorf("failed to marshal seeded data: %w", err) } // FNV-64a ETag over locale + JSON (faster than MD5, non-cryptographic). h := fnv.New64a() h.Write([]byte(locale)) h.Write([]byte{0}) h.Write(jsonData) etag := fmt.Sprintf("\"%x\"", h.Sum64()) if err := c.client.Set(ctx, seededDataKey(locale), jsonData, SeededDataTTL).Err(); err != nil { return "", fmt.Errorf("failed to cache seeded data: %w", err) } if err := c.client.Set(ctx, seededDataETagKey(locale), etag, SeededDataTTL).Err(); err != nil { return "", fmt.Errorf("failed to cache seeded data etag: %w", err) } return etag, nil } // GetCachedSeededData retrieves cached unified seeded data for a locale. func (c *CacheService) GetCachedSeededData(ctx context.Context, locale string, dest interface{}) error { return c.Get(ctx, seededDataKey(locale), dest) } // GetSeededDataETag retrieves the cached ETag for a locale's seeded data. func (c *CacheService) GetSeededDataETag(ctx context.Context, locale string) (string, error) { return c.GetString(ctx, seededDataETagKey(locale)) } // InvalidateSeededData removes cached seeded data and ETags for every // supported locale (lookup data is locale-independent at the source, so a // change must clear all language variants). func (c *CacheService) InvalidateSeededData(ctx context.Context) error { keys := make([]string, 0, len(i18n.SupportedLanguages)*2) for _, lang := range i18n.SupportedLanguages { keys = append(keys, seededDataKey(lang), seededDataETagKey(lang)) } return c.Delete(ctx, keys...) } // === User → Residence-IDs cache === // // Caches the set of residence IDs each user has access to. Hot read on // every authenticated API call (auth + tasks + residences + contractors + // documents all need it). Mutations on residences/share-codes invalidate // only the affected user(s); see Invalidate*ResidenceIDsForUsers. const ( residenceIDsKeyPrefix = "residence_ids_user:" residenceIDsTTL = 5 * time.Minute ) // CacheResidenceIDsForUser stores the residence-ID list for a user with a // 5-minute TTL. Membership rarely changes (only on share-code accept, // remove-user, delete-residence) so a 5-minute window catches the vast // majority of repeat reads while keeping staleness bounded. func (c *CacheService) CacheResidenceIDsForUser(ctx context.Context, userID uint, ids []uint) error { if c == nil { return nil } key := fmt.Sprintf("%s%d", residenceIDsKeyPrefix, userID) data, err := json.Marshal(ids) if err != nil { return err } return c.client.Set(ctx, key, data, residenceIDsTTL).Err() } // GetCachedResidenceIDsForUser fetches the cached residence-ID list. Returns // (nil, redis.Nil) when not cached so callers can distinguish from "user has // zero residences" (empty slice) — though for practical purposes both result // in an empty kanban response, so most callers can ignore the distinction. func (c *CacheService) GetCachedResidenceIDsForUser(ctx context.Context, userID uint) ([]uint, error) { if c == nil { return nil, fmt.Errorf("cache not available") } key := fmt.Sprintf("%s%d", residenceIDsKeyPrefix, userID) var ids []uint if err := c.Get(ctx, key, &ids); err != nil { return nil, err } return ids, nil } // InvalidateResidenceIDsForUsers drops the cache for one or more users. // Called from JoinWithCode (the joining user) and RemoveUser / // DeleteResidence (every affected user). Cheap — single Redis DEL per user. func (c *CacheService) InvalidateResidenceIDsForUsers(ctx context.Context, userIDs ...uint) error { if c == nil || len(userIDs) == 0 { return nil } keys := make([]string, len(userIDs)) for i, id := range userIDs { keys[i] = fmt.Sprintf("%s%d", residenceIDsKeyPrefix, id) } return c.Delete(ctx, keys...) } // === SubscriptionSettings cache === // // SubscriptionSettings is a 32-byte singleton row of admin-toggleable global // flags (EnableLimitations, EnableMonitoring, TrialEnabled, TrialDurationDays). // Read on every authed status check, every CreateResidence, and once per // 30s by every monitoring goroutine. Cached forever-ish here; admin writes // invalidate explicitly. // // 30-minute TTL is belt-and-suspenders against an admin update that somehow // bypasses the invalidation path (e.g., a manual SQL UPDATE). The flag value // converging within 30 min is fine for any real use case. const ( subscriptionSettingsKey = "subscription_settings:1" subscriptionSettingsTTL = 30 * time.Minute ) // CacheSubscriptionSettings stores the singleton settings row. Caller passes // any encodable value — typically *models.SubscriptionSettings. Best-effort. func (c *CacheService) CacheSubscriptionSettings(ctx context.Context, settings interface{}) error { if c == nil { return nil } data, err := json.Marshal(settings) if err != nil { return err } return c.client.Set(ctx, subscriptionSettingsKey, data, subscriptionSettingsTTL).Err() } // GetCachedSubscriptionSettings unmarshals into the supplied destination. // Returns redis.Nil on cache miss so callers can distinguish from genuine errors. func (c *CacheService) GetCachedSubscriptionSettings(ctx context.Context, dest interface{}) error { if c == nil { return fmt.Errorf("cache not available") } return c.Get(ctx, subscriptionSettingsKey, dest) } // InvalidateSubscriptionSettings drops the singleton-settings cache. Called // from admin handlers that update the row so the new values are visible // immediately to all pods (instead of waiting for the 30-min TTL). func (c *CacheService) InvalidateSubscriptionSettings(ctx context.Context) error { if c == nil { return nil } return c.Delete(ctx, subscriptionSettingsKey) } // === SubscriptionStatus cache (per-user) === // // SubscriptionStatusResponse aggregates subscription tier, all tier limits, and // per-user usage counts (residences/tasks/contractors/documents). The usage // part requires 4+ COUNT queries against the transatlantic Neon Postgres at // ~110ms RTT each — about a second of wall-clock per call before parallelism. // Caching the assembled response collapses that to a single Redis GET (~5ms). // // TTL is short (5 min) so stale state self-heals if any mutation path forgets // to invalidate. The primary correctness mechanism is explicit invalidation // via InvalidateSubscriptionStatusForUsers — called from every CRUD on // residences, tasks, contractors, documents, and subscription itself, fanning // out to every user with access to the affected residence. const ( subscriptionStatusKeyPrefix = "sub_status:user:" subscriptionStatusTTL = 5 * time.Minute ) // CacheSubscriptionStatus stores the assembled SubscriptionStatusResponse for // a user. Caller passes any encodable value to keep this package free of // service-layer types; subscription_service.go marshals/unmarshals. // Best-effort — Redis errors are returned but not fatal. func (c *CacheService) CacheSubscriptionStatus(ctx context.Context, userID uint, status interface{}) error { if c == nil { return nil } key := fmt.Sprintf("%s%d", subscriptionStatusKeyPrefix, userID) data, err := json.Marshal(status) if err != nil { return err } return c.client.Set(ctx, key, data, subscriptionStatusTTL).Err() } // GetCachedSubscriptionStatus unmarshals the cached response into dest. // Returns redis.Nil on cache miss so callers can distinguish from genuine errors. func (c *CacheService) GetCachedSubscriptionStatus(ctx context.Context, userID uint, dest interface{}) error { if c == nil { return fmt.Errorf("cache not available") } key := fmt.Sprintf("%s%d", subscriptionStatusKeyPrefix, userID) return c.Get(ctx, key, dest) } // InvalidateSubscriptionStatusForUsers drops the cached status for one or more // users. Used by every mutation that could change a user's usage counts — // residence create/delete/share, task/contractor/document CRUD, subscription // purchase/cancel/restore. Membership-changing residence ops fan out to every // user with access to that residence. func (c *CacheService) InvalidateSubscriptionStatusForUsers(ctx context.Context, userIDs ...uint) error { if c == nil || len(userIDs) == 0 { return nil } keys := make([]string, len(userIDs)) for i, id := range userIDs { keys[i] = fmt.Sprintf("%s%d", subscriptionStatusKeyPrefix, id) } return c.Delete(ctx, keys...) }