Enhance dashboard service with widget caching and improved query handling

- 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.
This commit is contained in:
Mazyar
2026-07-11 16:33:40 +03:30
parent 3e2700bc36
commit 81b0d94ba3
7 changed files with 747 additions and 74 deletions
+15 -2
View File
@@ -439,12 +439,25 @@ func (r *repository) NoticeTypes(ctx context.Context) (*NoticeTypesResponse, err
func (r *repository) ClosingSoon(ctx context.Context, limit int, windowSec int64) ([]ClosingSoonItem, error) {
now := time.Now().Unix()
windowEnd := now + windowSec
deadlineRange := bson.M{"$gt": now, "$lte": windowEnd}
pipeline := mongo.Pipeline{
{{Key: "$addFields", Value: bson.M{"effective_deadline": effectiveDeadlineExpr()}}},
{{Key: "$match", Value: bson.M{
"effective_deadline": bson.M{"$gt": now, "$lte": windowEnd},
"$or": bson.A{
bson.M{"submission_deadline": deadlineRange},
bson.M{
"$and": bson.A{
bson.M{"$or": bson.A{
bson.M{"submission_deadline": bson.M{"$exists": false}},
bson.M{"submission_deadline": nil},
bson.M{"submission_deadline": bson.M{"$lte": 0}},
}},
bson.M{"tender_deadline": deadlineRange},
},
},
},
}}},
{{Key: "$addFields", Value: bson.M{"effective_deadline": effectiveDeadlineExpr()}}},
{{Key: "$sort", Value: bson.M{"effective_deadline": 1}}},
{{Key: "$limit", Value: limit}},
{{Key: "$project", Value: bson.M{
+175 -68
View File
@@ -60,19 +60,30 @@ type service struct {
statisticsMu sync.Mutex
statistics map[int]cacheEntry[*StatisticsReportResponse]
statisticsGroup singleflight.Group
trendCache *widgetCache[*TrendResponse]
countriesCache *widgetCache[*CountriesResponse]
noticeTypesCache *widgetCache[*NoticeTypesResponse]
closingSoonCache *widgetCache[*ClosingSoonResponse]
recentCache *widgetCache[*RecentResponse]
}
// NewService creates a dashboard service.
func NewService(repo Repository, log logger.Logger, redisClient redis.Client) Service {
s := &service{
repo: repo,
logger: log,
redis: redisClient,
summary: make(map[string]cacheEntry[*SummaryResponse]),
statistics: make(map[int]cacheEntry[*StatisticsReportResponse]),
repo: repo,
logger: log,
redis: redisClient,
summary: make(map[string]cacheEntry[*SummaryResponse]),
statistics: make(map[int]cacheEntry[*StatisticsReportResponse]),
trendCache: newWidgetCache[*TrendResponse](redisClient, log, "trend"),
countriesCache: newWidgetCache[*CountriesResponse](redisClient, log, "countries"),
noticeTypesCache: newWidgetCache[*NoticeTypesResponse](redisClient, log, "notice-types"),
closingSoonCache: newWidgetCache[*ClosingSoonResponse](redisClient, log, "closing-soon"),
recentCache: newWidgetCache[*RecentResponse](redisClient, log, "recent"),
}
go s.warmStatisticsCache()
go s.warmSummaryCache()
go s.warmWidgetCaches()
return s
}
@@ -129,102 +140,134 @@ func (s *service) Summary(ctx context.Context, query SummaryQuery) (*SummaryResp
func (s *service) Trend(ctx context.Context, query TrendQuery) (*TrendResponse, error) {
days := trendDays(query.Days)
metric := normalizeTrendMetric(query.Metric)
startUnix := trendStartUnix(days)
cacheKey := fmt.Sprintf("%d:%s", days, metric)
s.logger.Info("Fetching dashboard trend", map[string]interface{}{
"days": days,
"metric": metric,
})
return s.trendCache.Get(ctx, cacheKey, func(loadCtx context.Context) (*TrendResponse, error) {
startUnix := trendStartUnix(days)
counts, err := s.repo.Trend(ctx, days, metric, startUnix)
if err != nil {
s.logger.Error("Failed to fetch dashboard trend", map[string]interface{}{
"error": err.Error(),
s.logger.Info("Fetching dashboard trend", map[string]interface{}{
"days": days,
"metric": metric,
})
return nil, fmt.Errorf("dashboard trend: %w", err)
}
series := fillTrendSeries(days, counts)
counts, err := s.repo.Trend(loadCtx, days, metric, startUnix)
if err != nil {
s.logger.Error("Failed to fetch dashboard trend", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("dashboard trend: %w", err)
}
return &TrendResponse{
Metric: metric,
Days: days,
Series: series,
}, nil
return &TrendResponse{
Metric: metric,
Days: days,
Series: fillTrendSeries(days, counts),
}, nil
})
}
func (s *service) Countries(ctx context.Context, query CountriesQuery) (*CountriesResponse, error) {
limit := countriesLimit(query.Limit)
cacheKey := strconv.Itoa(limit)
s.logger.Info("Fetching dashboard countries", map[string]interface{}{
"limit": limit,
})
out, err := s.repo.Countries(ctx, limit)
if err != nil {
s.logger.Error("Failed to fetch dashboard countries", map[string]interface{}{
"error": err.Error(),
return s.countriesCache.Get(ctx, cacheKey, func(loadCtx context.Context) (*CountriesResponse, error) {
s.logger.Info("Fetching dashboard countries", map[string]interface{}{
"limit": limit,
})
return nil, fmt.Errorf("dashboard countries: %w", err)
}
return out, nil
out, err := s.repo.Countries(loadCtx, limit)
if err != nil {
s.logger.Error("Failed to fetch dashboard countries", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("dashboard countries: %w", err)
}
return out, nil
})
}
func (s *service) NoticeTypes(ctx context.Context) (*NoticeTypesResponse, error) {
s.logger.Info("Fetching dashboard notice types", nil)
return s.noticeTypesCache.Get(ctx, "all", func(loadCtx context.Context) (*NoticeTypesResponse, error) {
s.logger.Info("Fetching dashboard notice types", nil)
out, err := s.repo.NoticeTypes(ctx)
if err != nil {
s.logger.Error("Failed to fetch dashboard notice types", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("dashboard notice types: %w", err)
}
out, err := s.repo.NoticeTypes(loadCtx)
if err != nil {
s.logger.Error("Failed to fetch dashboard notice types", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("dashboard notice types: %w", err)
}
return out, nil
return out, nil
})
}
func (s *service) ClosingSoon(ctx context.Context, query ClosingSoonQuery) (*ClosingSoonResponse, error) {
limit := listLimit(query.Limit)
windowHours := closingWindowHours(query.Window)
windowSec := int64(windowHours) * 3600
cacheKey := fmt.Sprintf("%d:%d", limit, windowSec)
s.logger.Info("Fetching dashboard closing soon", map[string]interface{}{
"limit": limit,
"window": windowHours,
})
items, err := s.repo.ClosingSoon(ctx, limit, windowSec)
if err != nil {
s.logger.Error("Failed to fetch dashboard closing soon", map[string]interface{}{
"error": err.Error(),
return s.closingSoonCache.Get(ctx, cacheKey, func(loadCtx context.Context) (*ClosingSoonResponse, error) {
s.logger.Info("Fetching dashboard closing soon", map[string]interface{}{
"limit": limit,
"window": windowHours,
})
return nil, fmt.Errorf("dashboard closing soon: %w", err)
}
return &ClosingSoonResponse{Items: items}, nil
items, err := s.repo.ClosingSoon(loadCtx, limit, windowSec)
if err != nil {
s.logger.Error("Failed to fetch dashboard closing soon", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("dashboard closing soon: %w", err)
}
return &ClosingSoonResponse{Items: items}, nil
})
}
func (s *service) Recent(ctx context.Context, query RecentQuery) (*RecentResponse, error) {
limit := listLimit(query.Limit)
s.logger.Info("Fetching dashboard recent tenders", map[string]interface{}{
"limit": limit,
})
items, nextCursor, err := s.repo.Recent(ctx, limit, strings.TrimSpace(query.Cursor))
if err != nil {
s.logger.Error("Failed to fetch dashboard recent tenders", map[string]interface{}{
"error": err.Error(),
cursor := strings.TrimSpace(query.Cursor)
if cursor != "" {
s.logger.Info("Fetching dashboard recent tenders", map[string]interface{}{
"limit": limit,
})
return nil, fmt.Errorf("dashboard recent: %w", err)
items, nextCursor, err := s.repo.Recent(ctx, limit, cursor)
if err != nil {
s.logger.Error("Failed to fetch dashboard recent tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("dashboard recent: %w", err)
}
return &RecentResponse{
Items: items,
NextCursor: nextCursor,
}, nil
}
return &RecentResponse{
Items: items,
NextCursor: nextCursor,
}, nil
cacheKey := strconv.Itoa(limit)
return s.recentCache.Get(ctx, cacheKey, func(loadCtx context.Context) (*RecentResponse, error) {
s.logger.Info("Fetching dashboard recent tenders", map[string]interface{}{
"limit": limit,
})
items, nextCursor, err := s.repo.Recent(loadCtx, limit, "")
if err != nil {
s.logger.Error("Failed to fetch dashboard recent tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("dashboard recent: %w", err)
}
return &RecentResponse{
Items: items,
NextCursor: nextCursor,
}, nil
})
}
func (s *service) Statistics(ctx context.Context, query StatisticsQuery) (*StatisticsReportResponse, error) {
@@ -339,6 +382,70 @@ func (s *service) loadStatistics(ctx context.Context, days int) (*StatisticsRepo
return out.(*StatisticsReportResponse), nil
}
func (s *service) warmWidgetCaches() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
defaultWindowSec := int64(defaultClosingWindowHours) * 3600
warmed := 0
if s.trendCache.WarmFromRedis(ctx, fmt.Sprintf("%d:%s", defaultTrendDays, "created")) {
warmed++
}
if s.countriesCache.WarmFromRedis(ctx, strconv.Itoa(defaultCountriesLimit)) {
warmed++
}
if s.noticeTypesCache.WarmFromRedis(ctx, "all") {
warmed++
}
if s.closingSoonCache.WarmFromRedis(ctx, fmt.Sprintf("%d:%d", defaultListLimit, defaultWindowSec)) {
warmed++
}
if s.recentCache.WarmFromRedis(ctx, strconv.Itoa(defaultListLimit)) {
warmed++
}
if warmed > 0 {
s.logger.Info("Dashboard widget caches warmed from Redis", map[string]interface{}{
"warmed": warmed,
})
}
go s.trendCache.Reload(fmt.Sprintf("%d:%s", defaultTrendDays, "created"), func(loadCtx context.Context) (*TrendResponse, error) {
return s.loadTrend(loadCtx, TrendQuery{Days: defaultTrendDays, Metric: "created"})
})
go s.countriesCache.Reload(strconv.Itoa(defaultCountriesLimit), func(loadCtx context.Context) (*CountriesResponse, error) {
return s.repo.Countries(loadCtx, defaultCountriesLimit)
})
go s.noticeTypesCache.Reload("all", func(loadCtx context.Context) (*NoticeTypesResponse, error) {
return s.repo.NoticeTypes(loadCtx)
})
go s.closingSoonCache.Reload(fmt.Sprintf("%d:%d", defaultListLimit, defaultWindowSec), func(loadCtx context.Context) (*ClosingSoonResponse, error) {
items, err := s.repo.ClosingSoon(loadCtx, defaultListLimit, defaultWindowSec)
if err != nil {
return nil, err
}
return &ClosingSoonResponse{Items: items}, nil
})
}
func (s *service) loadTrend(ctx context.Context, query TrendQuery) (*TrendResponse, error) {
days := trendDays(query.Days)
metric := normalizeTrendMetric(query.Metric)
startUnix := trendStartUnix(days)
counts, err := s.repo.Trend(ctx, days, metric, startUnix)
if err != nil {
return nil, err
}
return &TrendResponse{
Metric: metric,
Days: days,
Series: fillTrendSeries(days, counts),
}, nil
}
func (s *service) warmSummaryCache() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+224
View File
@@ -0,0 +1,224 @@
package dashboard
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"tm/pkg/logger"
"tm/pkg/redis"
goredis "github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
const (
widgetCacheTTL = 60 * time.Second
widgetStaleGraceTTL = 5 * time.Minute
widgetReloadTimeout = 2 * time.Minute
)
type widgetLoader[T any] func(ctx context.Context) (T, error)
type widgetCache[T any] struct {
mu sync.Mutex
entries map[string]cacheEntry[T]
group singleflight.Group
redis redis.Client
logger logger.Logger
keyPrefix string
ttl time.Duration
staleGrace time.Duration
}
func newWidgetCache[T any](redisClient redis.Client, log logger.Logger, keyPrefix string) *widgetCache[T] {
return &widgetCache[T]{
entries: make(map[string]cacheEntry[T]),
redis: redisClient,
logger: log,
keyPrefix: keyPrefix,
ttl: widgetCacheTTL,
staleGrace: widgetStaleGraceTTL,
}
}
func (c *widgetCache[T]) Get(ctx context.Context, key string, load widgetLoader[T]) (T, error) {
var zero T
if value, ok := c.fresh(key); ok {
return value, nil
}
if value, ok := c.stale(key); ok {
go c.Reload(key, load)
return value, nil
}
if value, ok := c.fromRedis(ctx, key); ok {
c.store(key, value)
go c.Reload(key, load)
return value, nil
}
out, err, _ := c.group.Do(key, func() (interface{}, error) {
if value, ok := c.fresh(key); ok {
return value, nil
}
result, err := load(context.WithoutCancel(ctx))
if err != nil {
return zero, err
}
c.store(key, result)
c.storeRedis(context.Background(), key, result)
return result, nil
})
if err != nil {
return zero, err
}
return out.(T), nil
}
func (c *widgetCache[T]) Reload(key string, load widgetLoader[T]) {
ctx, cancel := context.WithTimeout(context.Background(), widgetReloadTimeout)
defer cancel()
_, _, _ = c.group.Do("reload:"+key, func() (interface{}, error) {
result, err := load(ctx)
if err != nil {
if c.logger != nil {
c.logger.Warn("Failed to refresh dashboard widget cache", map[string]interface{}{
"widget": c.keyPrefix,
"key": key,
"error": err.Error(),
})
}
return nil, err
}
c.store(key, result)
c.storeRedis(context.Background(), key, result)
return result, nil
})
}
func (c *widgetCache[T]) WarmFromRedis(ctx context.Context, key string) bool {
value, ok := c.fromRedis(ctx, key)
if !ok {
return false
}
c.store(key, value)
return true
}
func (c *widgetCache[T]) fresh(key string) (T, bool) {
var zero T
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[key]
if !ok || now.After(entry.expiresAt) {
return zero, false
}
return entry.value, true
}
func (c *widgetCache[T]) stale(key string) (T, bool) {
var zero T
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[key]
if !ok || now.After(entry.staleUntil) {
if ok {
delete(c.entries, key)
}
return zero, false
}
return entry.value, true
}
func (c *widgetCache[T]) store(key string, value T) {
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = cacheEntry[T]{
expiresAt: now.Add(c.ttl),
staleUntil: now.Add(c.ttl + c.staleGrace),
value: value,
}
}
func (c *widgetCache[T]) redisKey(key string) string {
return fmt.Sprintf("dashboard:%s:%s", c.keyPrefix, key)
}
func (c *widgetCache[T]) fromRedis(ctx context.Context, key string) (T, bool) {
var zero T
if c.redis == nil {
return zero, false
}
raw, err := c.redis.Get(ctx, c.redisKey(key))
if err != nil {
if err != goredis.Nil && c.logger != nil {
c.logger.Warn("Failed to read dashboard widget cache from Redis", map[string]interface{}{
"widget": c.keyPrefix,
"key": key,
"error": err.Error(),
})
}
return zero, false
}
var value T
if err := json.Unmarshal([]byte(raw), &value); err != nil {
if c.logger != nil {
c.logger.Warn("Failed to decode dashboard widget cache from Redis", map[string]interface{}{
"widget": c.keyPrefix,
"key": key,
"error": err.Error(),
})
}
_ = c.redis.Del(ctx, c.redisKey(key))
return zero, false
}
return value, true
}
func (c *widgetCache[T]) storeRedis(ctx context.Context, key string, value T) {
if c.redis == nil {
return
}
encoded, err := json.Marshal(value)
if err != nil {
if c.logger != nil {
c.logger.Warn("Failed to encode dashboard widget cache for Redis", map[string]interface{}{
"widget": c.keyPrefix,
"key": key,
"error": err.Error(),
})
}
return
}
ttl := c.ttl + c.staleGrace
if err := c.redis.Set(ctx, c.redisKey(key), string(encoded), ttl); err != nil && c.logger != nil {
c.logger.Warn("Failed to store dashboard widget cache in Redis", map[string]interface{}{
"widget": c.keyPrefix,
"key": key,
"error": err.Error(),
})
}
}