c5e62a4061
- Added the `deadlineWithinWindowMatch` function to match raw deadline fields stored as Unix seconds or milliseconds, ensuring accurate filtering in MongoDB queries. - Updated the `ClosingSoon` method in the repository to utilize the new deadline matching logic, improving query accuracy for upcoming deadlines. - Introduced a new unit test, `TestDeadlineWithinWindowMatchCoversSecondsAndMilliseconds`, to validate the functionality of the deadline matching logic, ensuring both seconds and milliseconds are correctly handled. - Enhanced comments in the `widget_cache` and `search_list_cache` files for better clarity on cache behavior and staleness. This update improves the accuracy of deadline handling in the dashboard service and strengthens the test coverage for related functionalities.
241 lines
6.4 KiB
Go
241 lines
6.4 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 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(),
|
|
})
|
|
}
|
|
}
|