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.
231 lines
5.2 KiB
Go
231 lines
5.2 KiB
Go
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 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
|
|
)
|
|
|
|
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
|
|
|
|
// 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
|
|
}
|
|
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(),
|
|
})
|
|
}
|
|
}
|