From c5e62a406179c4c9be1b1a606893823ab1f96776 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sat, 11 Jul 2026 21:42:15 +0330 Subject: [PATCH] Implement deadline matching logic for dashboard repository and enhance unit tests - 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. --- internal/dashboard/repository.go | 39 +++++++++++++++++++++------ internal/dashboard/repository_test.go | 27 +++++++++++++++++++ internal/dashboard/service.go | 10 +++++++ internal/dashboard/widget_cache.go | 8 +++++- internal/tender/search_list_cache.go | 6 ++++- 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/internal/dashboard/repository.go b/internal/dashboard/repository.go index 55abae0..4d11691 100644 --- a/internal/dashboard/repository.go +++ b/internal/dashboard/repository.go @@ -439,25 +439,28 @@ 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: "$match", Value: bson.M{ "$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}, + bson.M{"submission_deadline": bson.M{"$gt": 0}}, + deadlineWithinWindowMatch("submission_deadline", now, windowEnd), + }, + }, + bson.M{ + "$and": bson.A{ + noSubmissionDeadlineClause(), + deadlineWithinWindowMatch("tender_deadline", now, windowEnd), }, }, }, }}}, {{Key: "$addFields", Value: bson.M{"effective_deadline": effectiveDeadlineExpr()}}}, + {{Key: "$match", Value: bson.M{ + "effective_deadline": bson.M{"$gt": now, "$lte": windowEnd}, + }}}, {{Key: "$sort", Value: bson.M{"effective_deadline": 1}}}, {{Key: "$limit", Value: limit}}, {{Key: "$project", Value: bson.M{ @@ -593,6 +596,26 @@ func trendTimestampField(metric string) string { } } +// deadlineWithinWindowMatch matches raw deadline fields stored as Unix seconds or +// milliseconds. normalizeTimestampExpr treats values > 1e12 as ms; indexed pre-filters +// must accept both encodings to stay equivalent to filtering on normalized deadlines. +func deadlineWithinWindowMatch(field string, now, windowEnd int64) bson.M { + return bson.M{ + "$or": bson.A{ + bson.M{field: bson.M{"$gt": now, "$lte": windowEnd}}, + bson.M{field: bson.M{"$gt": now * 1000, "$lte": windowEnd * 1000}}, + }, + } +} + +func noSubmissionDeadlineClause() bson.M { + return 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}}, + }} +} + // effectiveDeadlineExpr is used for closing-soon (submission first, per dashboard-api.md). func effectiveDeadlineExpr() bson.M { return normalizeDeadlineFieldExpr( diff --git a/internal/dashboard/repository_test.go b/internal/dashboard/repository_test.go index 222c1a1..36f2eeb 100644 --- a/internal/dashboard/repository_test.go +++ b/internal/dashboard/repository_test.go @@ -42,3 +42,30 @@ func TestFacetCurrencyValueDecodesBSOND(t *testing.T) { t.Fatalf("expected 12345.67, got %f", total) } } + +func TestDeadlineWithinWindowMatchCoversSecondsAndMilliseconds(t *testing.T) { + now := int64(1_700_000_000) + windowEnd := now + 86_400 + + match := deadlineWithinWindowMatch("submission_deadline", now, windowEnd) + orClause, ok := match["$or"].(bson.A) + if !ok || len(orClause) != 2 { + t.Fatalf("expected two range branches, got %#v", match) + } + + secRange, ok := orClause[0].(bson.M)["submission_deadline"].(bson.M) + if !ok { + t.Fatalf("expected seconds range on submission_deadline, got %#v", orClause[0]) + } + if secRange["$gt"] != now || secRange["$lte"] != windowEnd { + t.Fatalf("unexpected seconds range: %#v", secRange) + } + + msRange, ok := orClause[1].(bson.M)["submission_deadline"].(bson.M) + if !ok { + t.Fatalf("expected milliseconds range on submission_deadline, got %#v", orClause[1]) + } + if msRange["$gt"] != now*1000 || msRange["$lte"] != windowEnd*1000 { + t.Fatalf("unexpected milliseconds range: %#v", msRange) + } +} diff --git a/internal/dashboard/service.go b/internal/dashboard/service.go index 3cae0f0..7bffc25 100644 --- a/internal/dashboard/service.go +++ b/internal/dashboard/service.go @@ -427,6 +427,16 @@ func (s *service) warmWidgetCaches() { } return &ClosingSoonResponse{Items: items}, nil }) + go s.recentCache.Reload(strconv.Itoa(defaultListLimit), func(loadCtx context.Context) (*RecentResponse, error) { + items, nextCursor, err := s.repo.Recent(loadCtx, defaultListLimit, "") + if err != nil { + return nil, err + } + return &RecentResponse{ + Items: items, + NextCursor: nextCursor, + }, nil + }) } func (s *service) loadTrend(ctx context.Context, query TrendQuery) (*TrendResponse, error) { diff --git a/internal/dashboard/widget_cache.go b/internal/dashboard/widget_cache.go index 14b6c1e..e0b0b02 100644 --- a/internal/dashboard/widget_cache.go +++ b/internal/dashboard/widget_cache.go @@ -14,7 +14,11 @@ import ( ) const ( - widgetCacheTTL = 60 * time.Second + // widgetCacheTTL is the fresh window before a background reload is triggered. + widgetCacheTTL = 60 * time.Second + // widgetStaleGraceTTL is how long stale entries may be served while reloading. + // Dashboard widgets have no write-side invalidation; expect up to this lag after + // data changes (e.g. a new tender may not appear in cached widgets immediately). widgetStaleGraceTTL = 5 * time.Minute widgetReloadTimeout = 2 * time.Minute ) @@ -46,6 +50,8 @@ func newWidgetCache[T any](redisClient redis.Client, log logger.Logger, keyPrefi func (c *widgetCache[T]) Get(ctx context.Context, key string, load widgetLoader[T]) (T, error) { var zero T + // Returned values are shared across concurrent callers via singleflight and stale + // hits. Callers must treat them as read-only through JSON serialization. if value, ok := c.fresh(key); ok { return value, nil } diff --git a/internal/tender/search_list_cache.go b/internal/tender/search_list_cache.go index 6ee6892..05228e5 100644 --- a/internal/tender/search_list_cache.go +++ b/internal/tender/search_list_cache.go @@ -12,7 +12,9 @@ import ( ) const ( - searchListCacheTTL = 60 * time.Second + 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:" ) @@ -92,6 +94,8 @@ func (s *tenderService) cachedSearchList(ctx context.Context, form *SearchForm, 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