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.
This commit is contained in:
Mazyar
2026-07-11 21:42:15 +03:30
parent 81b0d94ba3
commit c5e62a4061
5 changed files with 80 additions and 10 deletions
+31 -8
View File
@@ -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) { func (r *repository) ClosingSoon(ctx context.Context, limit int, windowSec int64) ([]ClosingSoonItem, error) {
now := time.Now().Unix() now := time.Now().Unix()
windowEnd := now + windowSec windowEnd := now + windowSec
deadlineRange := bson.M{"$gt": now, "$lte": windowEnd}
pipeline := mongo.Pipeline{ pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{ {{Key: "$match", Value: bson.M{
"$or": bson.A{ "$or": bson.A{
bson.M{"submission_deadline": deadlineRange},
bson.M{ bson.M{
"$and": bson.A{ "$and": bson.A{
bson.M{"$or": bson.A{ bson.M{"submission_deadline": bson.M{"$gt": 0}},
bson.M{"submission_deadline": bson.M{"$exists": false}}, deadlineWithinWindowMatch("submission_deadline", now, windowEnd),
bson.M{"submission_deadline": nil}, },
bson.M{"submission_deadline": bson.M{"$lte": 0}}, },
}}, bson.M{
bson.M{"tender_deadline": deadlineRange}, "$and": bson.A{
noSubmissionDeadlineClause(),
deadlineWithinWindowMatch("tender_deadline", now, windowEnd),
}, },
}, },
}, },
}}}, }}},
{{Key: "$addFields", Value: bson.M{"effective_deadline": effectiveDeadlineExpr()}}}, {{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: "$sort", Value: bson.M{"effective_deadline": 1}}},
{{Key: "$limit", Value: limit}}, {{Key: "$limit", Value: limit}},
{{Key: "$project", Value: bson.M{ {{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). // effectiveDeadlineExpr is used for closing-soon (submission first, per dashboard-api.md).
func effectiveDeadlineExpr() bson.M { func effectiveDeadlineExpr() bson.M {
return normalizeDeadlineFieldExpr( return normalizeDeadlineFieldExpr(
+27
View File
@@ -42,3 +42,30 @@ func TestFacetCurrencyValueDecodesBSOND(t *testing.T) {
t.Fatalf("expected 12345.67, got %f", total) 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)
}
}
+10
View File
@@ -427,6 +427,16 @@ func (s *service) warmWidgetCaches() {
} }
return &ClosingSoonResponse{Items: items}, nil 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) { func (s *service) loadTrend(ctx context.Context, query TrendQuery) (*TrendResponse, error) {
+7 -1
View File
@@ -14,7 +14,11 @@ import (
) )
const ( 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 widgetStaleGraceTTL = 5 * time.Minute
widgetReloadTimeout = 2 * 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) { func (c *widgetCache[T]) Get(ctx context.Context, key string, load widgetLoader[T]) (T, error) {
var zero T 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 { if value, ok := c.fresh(key); ok {
return value, nil return value, nil
} }
+5 -1
View File
@@ -12,7 +12,9 @@ import (
) )
const ( 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 searchListCacheStaleTTL = 5 * time.Minute
searchListCacheKeyPrefix = "tender:search:list:" searchListCacheKeyPrefix = "tender:search:list:"
) )
@@ -92,6 +94,8 @@ func (s *tenderService) cachedSearchList(ctx context.Context, form *SearchForm,
return load(ctx) 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) cacheKey := searchListCacheKey(pagination, language)
redisKey := searchListCacheKeyPrefix + cacheKey redisKey := searchListCacheKeyPrefix + cacheKey