81b0d94ba3
- Introduced a new `widgetCache` structure to manage caching for various dashboard widgets, improving performance and reducing redundant data retrieval. - Updated the `ClosingSoon` and `Trend` methods in the service to utilize the new caching mechanism, ensuring efficient data access and reducing load on the database. - Enhanced the `NoticeTypes` and `Countries` methods to implement caching, further optimizing the dashboard's responsiveness. - Added a new `searchListCache` implementation in the tender service to cache search results, improving the efficiency of tender searches and reducing database load. - Implemented robust error handling and logging for cache operations, ensuring better visibility into potential issues. This update significantly enhances the performance and scalability of the dashboard and tender services by integrating effective caching strategies, leading to improved user experience and resource management.
237 lines
6.1 KiB
Go
237 lines
6.1 KiB
Go
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 = 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)
|
|
}
|
|
|
|
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(),
|
|
})
|
|
}
|
|
}
|