3002935b76
continuous-integration/drone/push Build is passing
- Increased the cache duration for dashboard statistics from 60 seconds to 5 minutes, improving data freshness and reducing load on the backend. - Introduced a stale cache mechanism that allows retrieval of stale statistics while refreshing them in the background, enhancing user experience by providing quicker access to data. - Updated the statistics repository to handle the new caching logic, ensuring accurate and timely statistics reporting. - Added tests to validate the new caching behavior and ensure the integrity of statistics retrieval. This update optimizes the dashboard's performance and responsiveness by improving the caching strategy for statistics.
389 lines
9.6 KiB
Go
389 lines
9.6 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"tm/pkg/logger"
|
|
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
const (
|
|
defaultClosingWindowHours = 168
|
|
defaultTrendDays = 14
|
|
maxTrendDays = 90
|
|
defaultCountriesLimit = 6
|
|
defaultListLimit = 5
|
|
maxListLimit = 20
|
|
summaryCacheTTL = 60 * time.Second
|
|
statisticsCacheTTL = 5 * time.Minute
|
|
statisticsStaleGraceTTL = 15 * time.Minute
|
|
)
|
|
|
|
// Service defines dashboard business operations.
|
|
type Service interface {
|
|
Summary(ctx context.Context, query SummaryQuery) (*SummaryResponse, error)
|
|
Trend(ctx context.Context, query TrendQuery) (*TrendResponse, error)
|
|
Countries(ctx context.Context, query CountriesQuery) (*CountriesResponse, error)
|
|
NoticeTypes(ctx context.Context) (*NoticeTypesResponse, error)
|
|
ClosingSoon(ctx context.Context, query ClosingSoonQuery) (*ClosingSoonResponse, error)
|
|
Recent(ctx context.Context, query RecentQuery) (*RecentResponse, error)
|
|
Statistics(ctx context.Context, query StatisticsQuery) (*StatisticsReportResponse, error)
|
|
}
|
|
|
|
type cacheEntry[T any] struct {
|
|
expiresAt time.Time
|
|
staleUntil time.Time
|
|
value T
|
|
}
|
|
|
|
type service struct {
|
|
repo Repository
|
|
logger logger.Logger
|
|
summaryMu sync.Mutex
|
|
summary map[int64]cacheEntry[*SummaryResponse]
|
|
summaryGroup singleflight.Group
|
|
statisticsMu sync.Mutex
|
|
statistics map[int]cacheEntry[*StatisticsReportResponse]
|
|
statisticsGroup singleflight.Group
|
|
}
|
|
|
|
// NewService creates a dashboard service.
|
|
func NewService(repo Repository, log logger.Logger) Service {
|
|
return &service{
|
|
repo: repo,
|
|
logger: log,
|
|
summary: make(map[int64]cacheEntry[*SummaryResponse]),
|
|
statistics: make(map[int]cacheEntry[*StatisticsReportResponse]),
|
|
}
|
|
}
|
|
|
|
func (s *service) Summary(ctx context.Context, query SummaryQuery) (*SummaryResponse, error) {
|
|
windowHours := closingWindowHours(query.ClosingWindow)
|
|
windowSec := int64(windowHours) * 3600
|
|
|
|
if cached, ok := s.cachedSummary(windowSec); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
cacheKey := strconv.FormatInt(windowSec, 10)
|
|
out, err, _ := s.summaryGroup.Do(cacheKey, func() (interface{}, error) {
|
|
if cached, ok := s.cachedSummary(windowSec); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
s.logger.Info("Fetching dashboard summary", map[string]interface{}{
|
|
"closing_window_hours": windowHours,
|
|
})
|
|
|
|
result, err := s.repo.Summary(context.WithoutCancel(ctx), windowSec)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard summary", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard summary: %w", err)
|
|
}
|
|
|
|
s.storeSummary(windowSec, result)
|
|
return result, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return out.(*SummaryResponse), nil
|
|
}
|
|
|
|
func (s *service) Trend(ctx context.Context, query TrendQuery) (*TrendResponse, error) {
|
|
days := trendDays(query.Days)
|
|
metric := normalizeTrendMetric(query.Metric)
|
|
startUnix := trendStartUnix(days)
|
|
|
|
s.logger.Info("Fetching dashboard trend", map[string]interface{}{
|
|
"days": days,
|
|
"metric": metric,
|
|
})
|
|
|
|
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(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard trend: %w", err)
|
|
}
|
|
|
|
series := fillTrendSeries(days, counts)
|
|
|
|
return &TrendResponse{
|
|
Metric: metric,
|
|
Days: days,
|
|
Series: series,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) Countries(ctx context.Context, query CountriesQuery) (*CountriesResponse, error) {
|
|
limit := countriesLimit(query.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 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)
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
|
|
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 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(),
|
|
})
|
|
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) {
|
|
days := trendDays(query.Days)
|
|
if cached, ok := s.cachedStatistics(days); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
if stale, ok := s.staleStatistics(days); ok {
|
|
go s.refreshStatistics(days)
|
|
return stale, nil
|
|
}
|
|
|
|
return s.loadStatistics(ctx, days)
|
|
}
|
|
|
|
func (s *service) refreshStatistics(days int) {
|
|
_, _ = s.loadStatistics(context.Background(), days)
|
|
}
|
|
|
|
func (s *service) loadStatistics(ctx context.Context, days int) (*StatisticsReportResponse, error) {
|
|
cacheKey := strconv.Itoa(days)
|
|
out, err, _ := s.statisticsGroup.Do(cacheKey, func() (interface{}, error) {
|
|
if cached, ok := s.cachedStatistics(days); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
startUnix := trendStartUnix(days)
|
|
|
|
s.logger.Info("Fetching dashboard statistics report", map[string]interface{}{
|
|
"days": days,
|
|
})
|
|
|
|
result, err := s.repo.Statistics(context.WithoutCancel(ctx), days, startUnix)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard statistics report", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard statistics: %w", err)
|
|
}
|
|
|
|
s.storeStatistics(days, result)
|
|
return result, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return out.(*StatisticsReportResponse), nil
|
|
}
|
|
|
|
func (s *service) cachedSummary(windowSec int64) (*SummaryResponse, bool) {
|
|
now := time.Now()
|
|
|
|
s.summaryMu.Lock()
|
|
defer s.summaryMu.Unlock()
|
|
|
|
entry, ok := s.summary[windowSec]
|
|
if !ok || now.After(entry.expiresAt) {
|
|
if ok {
|
|
delete(s.summary, windowSec)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
return entry.value, true
|
|
}
|
|
|
|
func (s *service) storeSummary(windowSec int64, value *SummaryResponse) {
|
|
s.summaryMu.Lock()
|
|
defer s.summaryMu.Unlock()
|
|
|
|
s.summary[windowSec] = cacheEntry[*SummaryResponse]{
|
|
expiresAt: time.Now().Add(summaryCacheTTL),
|
|
staleUntil: time.Now().Add(summaryCacheTTL),
|
|
value: value,
|
|
}
|
|
}
|
|
|
|
func (s *service) cachedStatistics(days int) (*StatisticsReportResponse, bool) {
|
|
now := time.Now()
|
|
|
|
s.statisticsMu.Lock()
|
|
defer s.statisticsMu.Unlock()
|
|
|
|
entry, ok := s.statistics[days]
|
|
if !ok || now.After(entry.expiresAt) {
|
|
return nil, false
|
|
}
|
|
|
|
return entry.value, true
|
|
}
|
|
|
|
func (s *service) staleStatistics(days int) (*StatisticsReportResponse, bool) {
|
|
now := time.Now()
|
|
|
|
s.statisticsMu.Lock()
|
|
defer s.statisticsMu.Unlock()
|
|
|
|
entry, ok := s.statistics[days]
|
|
if !ok || now.After(entry.staleUntil) {
|
|
if ok {
|
|
delete(s.statistics, days)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
return entry.value, true
|
|
}
|
|
|
|
func (s *service) storeStatistics(days int, value *StatisticsReportResponse) {
|
|
now := time.Now()
|
|
|
|
s.statisticsMu.Lock()
|
|
defer s.statisticsMu.Unlock()
|
|
|
|
s.statistics[days] = cacheEntry[*StatisticsReportResponse]{
|
|
expiresAt: now.Add(statisticsCacheTTL),
|
|
staleUntil: now.Add(statisticsCacheTTL + statisticsStaleGraceTTL),
|
|
value: value,
|
|
}
|
|
}
|
|
|
|
func closingWindowHours(hours int) int {
|
|
if hours <= 0 {
|
|
return defaultClosingWindowHours
|
|
}
|
|
return hours
|
|
}
|
|
|
|
func trendDays(days int) int {
|
|
if days <= 0 {
|
|
return defaultTrendDays
|
|
}
|
|
if days > maxTrendDays {
|
|
return maxTrendDays
|
|
}
|
|
return days
|
|
}
|
|
|
|
func countriesLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return defaultCountriesLimit
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func listLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return defaultListLimit
|
|
}
|
|
if limit > maxListLimit {
|
|
return maxListLimit
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func normalizeTrendMetric(metric string) string {
|
|
switch strings.ToLower(strings.TrimSpace(metric)) {
|
|
case "published", "awarded":
|
|
return strings.ToLower(strings.TrimSpace(metric))
|
|
default:
|
|
return "created"
|
|
}
|
|
}
|
|
|
|
func trendStartUnix(days int) int64 {
|
|
now := time.Now().UTC()
|
|
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
start := end.AddDate(0, 0, -(days - 1))
|
|
return start.Unix()
|
|
}
|
|
|
|
func fillTrendSeries(days int, counts map[string]int64) []TrendPoint {
|
|
now := time.Now().UTC()
|
|
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
start := end.AddDate(0, 0, -(days-1))
|
|
|
|
series := make([]TrendPoint, 0, days)
|
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
date := d.Format("2006-01-02")
|
|
series = append(series, TrendPoint{
|
|
Date: date,
|
|
Count: counts[date],
|
|
})
|
|
}
|
|
|
|
return series
|
|
}
|