diff --git a/services/wallet/market/cache.go b/services/wallet/market/cache.go new file mode 100644 index 000000000..1c75053a6 --- /dev/null +++ b/services/wallet/market/cache.go @@ -0,0 +1,67 @@ +package market + +import ( + "sync" + "time" +) + +type cacheItem[V any] struct { + value V + expiration time.Time +} + +type Cache[K comparable, V any] struct { + data map[K]cacheItem[V] + lock sync.RWMutex + ttl time.Duration + fetcher func(key K) (V, error) +} + +func NewCache[K comparable, V any](ttl time.Duration, fetchFn func(key K) (V, error)) *Cache[K, V] { + return &Cache[K, V]{ + data: make(map[K]cacheItem[V]), + ttl: ttl, + fetcher: fetchFn, + } +} + +func (c *Cache[K, V]) Get(key K, fresh bool) (V, error) { + if fresh { + return c.refresh(key, fresh) + } + + c.lock.RLock() + item, exists := c.data[key] + c.lock.RUnlock() + + if exists && time.Now().Before(item.expiration) { + return item.value, nil + } + + return c.refresh(key, fresh) +} + +func (c *Cache[K, V]) refresh(key K, fresh bool) (V, error) { + c.lock.Lock() + defer c.lock.Unlock() + + if !fresh { + item, exists := c.data[key] + if exists && time.Now().Before(item.expiration) { + return item.value, nil + } + } + + value, err := c.fetcher(key) + if err != nil { + var zero V + return zero, err + } + + c.data[key] = cacheItem[V]{ + value: value, + expiration: time.Now().Add(c.ttl), + } + + return value, nil +} diff --git a/services/wallet/market/market.go b/services/wallet/market/market.go index 376f80cb5..f82e8e2c4 100644 --- a/services/wallet/market/market.go +++ b/services/wallet/market/market.go @@ -2,6 +2,7 @@ package market import ( "context" + "strings" "sync" "time" @@ -143,10 +144,29 @@ func (pm *Manager) FetchTokenMarketValues(symbols []string, currency string) (ma } marketValues := result.(map[string]thirdparty.TokenMarketValues) - pm.updateMarketCache(currency, marketValues) return marketValues, nil } +func (pm *Manager) MakeCacheForFetchTokenMarketValues(ttl time.Duration) *Cache[string, map[string]thirdparty.TokenMarketValues] { + parseKey := func(key string) (string, []string) { + keyParts := strings.Split(key, "-") + currency := keyParts[0] + tokenSymbols := keyParts[1:] + return currency, tokenSymbols + } + + fetcher := func(key string) (map[string]thirdparty.TokenMarketValues, error) { + currency, tokenSymbols := parseKey(key) + return pm.FetchTokenMarketValues(tokenSymbols, currency) + } + + return NewCache(ttl, fetcher) +} + +func GenerateCacheKeyForFetchTokenMarketValues(currency string, tokenSymbols []string) string { + return currency + "-" + strings.Join(tokenSymbols, "-") +} + func (pm *Manager) GetCachedTokenMarketValues() MarketValuesPerCurrencyAndToken { pm.marketCacheLock.RLock() defer pm.marketCacheLock.RUnlock() diff --git a/services/wallet/market/market_test.go b/services/wallet/market/market_test.go index fc69d178a..c8d329e31 100644 --- a/services/wallet/market/market_test.go +++ b/services/wallet/market/market_test.go @@ -3,6 +3,7 @@ package market import ( "errors" "testing" + "time" "github.com/golang/mock/gomock" @@ -184,7 +185,7 @@ func TestFetchTokenMarketValues(t *testing.T) { require.Nil(t, marketValues) } -func TestGetOrFetchTokenMarketValues(t *testing.T) { +func TestCachedFetchTokenMarketValues(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -235,74 +236,41 @@ func TestGetOrFetchTokenMarketValues(t *testing.T) { provider.EXPECT().ID().Return("MockMarketProvider").AnyTimes() manager := setupMarketManager(t, []thirdparty.MarketDataProvider{provider}) + ttl := 200 * time.Millisecond + cache := manager.MakeCacheForFetchTokenMarketValues(ttl) + cacheKey := GenerateCacheKeyForFetchTokenMarketValues(currency, symbols) + fresh := false + // Test: ensure errors are propagated provider.EXPECT().FetchTokenMarketValues(symbols, currency).Return(nil, errors.New("error")) - marketValues, err := manager.GetOrFetchTokenMarketValues(symbols, currency, 0) + marketValues, err := cache.Get(cacheKey, fresh) require.Error(t, err) require.Nil(t, marketValues) // Test: ensure token market values are retrieved provider.EXPECT().FetchTokenMarketValues(symbols, currency).Return(initialTokenMarketValues, nil) - marketValues, err = manager.GetOrFetchTokenMarketValues(symbols, currency, 10) + marketValues, err = cache.Get(cacheKey, fresh) require.NoError(t, err) require.Equal(t, initialTokenMarketValues, marketValues) // Test: ensure token market values are cached - provider.EXPECT().FetchTokenMarketValues(symbols, currency).Return(updatedTokenMarketValues, nil) - marketValues, err = manager.GetOrFetchTokenMarketValues(symbols, currency, 10) + provider.EXPECT().FetchTokenMarketValues(symbols, currency).Return(updatedTokenMarketValues, nil).MaxTimes(0) + marketValues, err = cache.Get(cacheKey, fresh) require.NoError(t, err) require.Equal(t, initialTokenMarketValues, marketValues) - // Test: ensure token market values are updated - marketValues, err = manager.GetOrFetchTokenMarketValues(symbols, currency, -1) + // Test: ensure token market values are updated when requesting fresh data + provider.EXPECT().FetchTokenMarketValues(symbols, currency).Return(updatedTokenMarketValues, nil).Times(1) + fresh = true + marketValues, err = cache.Get(cacheKey, fresh) require.NoError(t, err) require.Equal(t, updatedTokenMarketValues, marketValues) -} -func TestGetCachedTokenMarketValues(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - symbols := []string{"BTC", "ETH"} - currency := "EUR" - initialTokenMarketValues := map[string]thirdparty.TokenMarketValues{ - "BTC": { - MKTCAP: 1000000000, - HIGHDAY: 1.23456, - LOWDAY: 1.00000, - CHANGEPCTHOUR: 0.1, - CHANGEPCTDAY: 0.2, - CHANGEPCT24HOUR: 0.3, - CHANGE24HOUR: 0.4, - }, - "ETH": { - MKTCAP: 2000000000, - HIGHDAY: 4.56789, - LOWDAY: 4.00000, - CHANGEPCTHOUR: 0.5, - CHANGEPCTDAY: 0.6, - CHANGEPCT24HOUR: 0.7, - CHANGE24HOUR: 0.8, - }, - } - - provider := mock_thirdparty.NewMockMarketDataProvider(ctrl) - provider.EXPECT().ID().Return("MockMarketProvider").AnyTimes() - manager := setupMarketManager(t, []thirdparty.MarketDataProvider{provider}) - - // Test: ensure token market cache is empty - tokenMarketCache := manager.GetCachedTokenMarketValues() - require.Empty(t, tokenMarketCache) - - // Test: ensure token market values are retrieved - provider.EXPECT().FetchTokenMarketValues(symbols, currency).Return(initialTokenMarketValues, nil) - marketValues, err := manager.GetOrFetchTokenMarketValues(symbols, currency, 10) - tokenMarketCache = manager.GetCachedTokenMarketValues() + // Test: stale data is ignored and the cache is refreshed + time.Sleep(ttl + time.Millisecond) + provider.EXPECT().FetchTokenMarketValues(symbols, currency).Return(initialTokenMarketValues, nil).Times(1) + fresh = false + marketValues, err = cache.Get(cacheKey, fresh) require.NoError(t, err) - - for _, token := range symbols { - tokenMarketValues := marketValues[token] - cachedTokenMarketValues := tokenMarketCache[currency][token] - require.Equal(t, cachedTokenMarketValues.MarketValues, tokenMarketValues) - } + require.Equal(t, initialTokenMarketValues, marketValues) } diff --git a/services/wallet/reader.go b/services/wallet/reader.go index 6ba465897..7d3d98f6e 100644 --- a/services/wallet/reader.go +++ b/services/wallet/reader.go @@ -4,6 +4,7 @@ import ( "context" "math" "math/big" + "strings" "sync" "time" @@ -501,9 +502,13 @@ func (r *Reader) GetWalletToken(ctx context.Context, clients map[uint64]chain.Cl }) group.Add(func(parent context.Context) error { - tokenMarketValues, err = r.marketManager.GetOrFetchTokenMarketValues(tokenSymbols, currency, 60) + ttl := 60 * time.Second + cache := r.marketManager.MakeCacheForFetchTokenMarketValues(ttl) + + fresh := false + tokenMarketValues, err = cache.Get(market.GenerateCacheKeyForFetchTokenMarketValues(currency, tokenSymbols), fresh) if err != nil { - log.Info("marketManager.GetOrFetchTokenMarketValues err", err) + log.Info("marketManager.FetchTokenMarketValues err", err) } return nil })