package tender import ( "context" "encoding/json" "fmt" "strings" "time" goredis "github.com/redis/go-redis/v9" "tm/pkg/response" ) const ( searchListCacheTTL = 60 * time.Second // searchListCacheStaleTTL bounds how long the default unfiltered list may lag after // writes. Only isCacheableSearchList queries use this cache (~5 min max staleness). searchListCacheStaleTTL = 5 * time.Minute searchListCacheKeyPrefix = "tender:search:list:" ) type searchListCacheEntry struct { expiresAt time.Time staleUntil time.Time value *SearchResponse } type cachedSearchListPayload struct { Tenders []TenderResponse `json:"tenders"` Metadata *response.Meta `json:"metadata,omitempty"` } func searchResponseToCachePayload(resp *SearchResponse) *cachedSearchListPayload { if resp == nil { return nil } return &cachedSearchListPayload{ Tenders: resp.Tenders, Metadata: resp.Metadata, } } func cachePayloadToSearchResponse(payload *cachedSearchListPayload) *SearchResponse { if payload == nil { return nil } return &SearchResponse{ Tenders: payload.Tenders, Metadata: payload.Metadata, } } func isCacheableSearchList(form *SearchForm, pagination *response.Pagination) bool { if form == nil || pagination == nil { return false } if form.HasListFilters() { return false } if form.CompanyID != nil && strings.TrimSpace(*form.CompanyID) != "" { return false } if form.CustomerID != nil && strings.TrimSpace(*form.CustomerID) != "" { return false } if len(form.CompanyIDs) > 0 { return false } if pagination.Cursor != "" || pagination.IncludeTotal || pagination.Offset != 0 { return false } if pagination.Limit <= 0 || pagination.Limit > 20 { return false } sortBy := pagination.SortBy if sortBy == "" { sortBy = "created_at" } sortOrder := pagination.SortOrder if sortOrder == "" { sortOrder = "desc" } return sortBy == "created_at" && sortOrder == "desc" } func searchListCacheKey(pagination *response.Pagination, language string) string { return fmt.Sprintf("%d:%s", pagination.Limit, strings.ToLower(strings.TrimSpace(language))) } func (s *tenderService) cachedSearchList(ctx context.Context, form *SearchForm, pagination *response.Pagination, language string, load func(context.Context) (*SearchResponse, error)) (*SearchResponse, error) { if !isCacheableSearchList(form, pagination) { return load(ctx) } // Cached *SearchResponse values (including the Tenders slice) are shared across // concurrent callers via singleflight and stale hits; treat as read-only. cacheKey := searchListCacheKey(pagination, language) redisKey := searchListCacheKeyPrefix + cacheKey if cached, ok := s.getFreshSearchListCache(cacheKey); ok { return cached, nil } if stale, ok := s.getStaleSearchListCache(cacheKey); ok { go s.refreshSearchListCache(cacheKey, redisKey, load) return stale, nil } if cached, ok := s.getRedisSearchListCache(ctx, redisKey); ok { s.storeSearchListCache(cacheKey, cached) go s.refreshSearchListCache(cacheKey, redisKey, load) return cached, nil } out, err, _ := s.searchListCacheGroup.Do(cacheKey, func() (interface{}, error) { if cached, ok := s.getFreshSearchListCache(cacheKey); ok { return cached, nil } result, err := load(context.WithoutCancel(ctx)) if err != nil { return nil, err } s.storeSearchListCache(cacheKey, result) s.storeRedisSearchListCache(context.Background(), redisKey, result) return result, nil }) if err != nil { return nil, err } return out.(*SearchResponse), nil } func (s *tenderService) refreshSearchListCache(cacheKey, redisKey string, load func(context.Context) (*SearchResponse, error)) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() result, err := load(ctx) if err != nil { s.logger.Warn("Failed to refresh tender search list cache", map[string]interface{}{ "cache_key": cacheKey, "error": err.Error(), }) return } s.storeSearchListCache(cacheKey, result) s.storeRedisSearchListCache(context.Background(), redisKey, result) } func (s *tenderService) getFreshSearchListCache(cacheKey string) (*SearchResponse, bool) { s.searchListCacheMu.Lock() defer s.searchListCacheMu.Unlock() entry, ok := s.searchListCache[cacheKey] if !ok || time.Now().After(entry.expiresAt) { return nil, false } return entry.value, true } func (s *tenderService) getStaleSearchListCache(cacheKey string) (*SearchResponse, bool) { s.searchListCacheMu.Lock() defer s.searchListCacheMu.Unlock() entry, ok := s.searchListCache[cacheKey] if !ok || time.Now().After(entry.staleUntil) { if ok { delete(s.searchListCache, cacheKey) } return nil, false } return entry.value, true } func (s *tenderService) storeSearchListCache(cacheKey string, value *SearchResponse) { now := time.Now() s.searchListCacheMu.Lock() defer s.searchListCacheMu.Unlock() s.searchListCache[cacheKey] = searchListCacheEntry{ expiresAt: now.Add(searchListCacheTTL), staleUntil: now.Add(searchListCacheTTL + searchListCacheStaleTTL), value: value, } } func (s *tenderService) getRedisSearchListCache(ctx context.Context, redisKey string) (*SearchResponse, bool) { if s.redisClient == nil { return nil, false } raw, err := s.redisClient.Get(ctx, redisKey) if err != nil { if err != goredis.Nil { s.logger.Warn("Failed to read tender search list cache from Redis", map[string]interface{}{ "cache_key": redisKey, "error": err.Error(), }) } return nil, false } var cached cachedSearchListPayload if err := json.Unmarshal([]byte(raw), &cached); err != nil { s.logger.Warn("Failed to decode tender search list cache from Redis", map[string]interface{}{ "cache_key": redisKey, "error": err.Error(), }) _ = s.redisClient.Del(ctx, redisKey) return nil, false } return cachePayloadToSearchResponse(&cached), true } func (s *tenderService) storeRedisSearchListCache(ctx context.Context, redisKey string, value *SearchResponse) { if s.redisClient == nil || value == nil { return } encoded, err := json.Marshal(searchResponseToCachePayload(value)) if err != nil { s.logger.Warn("Failed to encode tender search list cache for Redis", map[string]interface{}{ "cache_key": redisKey, "error": err.Error(), }) return } if err := s.redisClient.Set(ctx, redisKey, string(encoded), searchListCacheTTL+searchListCacheStaleTTL); err != nil { s.logger.Warn("Failed to store tender search list cache in Redis", map[string]interface{}{ "cache_key": redisKey, "error": err.Error(), }) } }