Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add caching for PriceReader #372

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
3 changes: 3 additions & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ packages:
interfaces:
ChainSupport:
PluginProcessor:
github.com/smartcontractkit/chainlink-ccip/internal/cache:
interfaces:
Cache:
github.com/smartcontractkit/chainlink-ccip/commit/merkleroot/rmn:
interfaces:
Controller:
Expand Down
120 changes: 120 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package cache

import (
"sync"
"time"

"github.com/patrickmn/go-cache"
)

const (
NoExpiration = cache.NoExpiration
)

// Package cache provides a generic caching implementation that wraps the go-cache library
// with additional support for custom eviction policies. It allows both time-based expiration
// (inherited from go-cache) and custom eviction rules through user-defined policies.
//
// The cache is type-safe through Go generics, thread-safe through mutex locks, and supports
// all basic cache operations (Get, Set, Delete, Flush). Keys are strings, and values can
// be of any type.
//
// Example usage:
//
// // Create a cache with custom eviction policy
// cache := cache.NewCustomCache[int](
// 5*time.Minute, // Default expiration
// 10*time.Minute, // Cleanup interval
// func(v int) bool {
// return v < 0 // Evict negative numbers
0xnogo marked this conversation as resolved.
Show resolved Hide resolved
// },
// )
//
// // Use NoExpiration for items that shouldn't expire
// cache.Set("key", 42, cache.NoExpiration)
//
// The cache can be used with any value type while maintaining type safety:
// - Time-based expiration is handled by the underlying go-cache
// - Custom policies can implement domain-specific eviction logic
// - Thread-safe operations for concurrent access
type CustomCache[V any] struct {
cache *cache.Cache
customPolicy func(V) bool
0xnogo marked this conversation as resolved.
Show resolved Hide resolved
mutex sync.RWMutex
}

// NewCustomCache creates a new cache with both time-based and custom eviction policies
func NewCustomCache[V any](
defaultExpiration time.Duration,
cleanupInterval time.Duration,
customPolicy func(V) bool,
) *CustomCache[V] {
return &CustomCache[V]{
cache: cache.New(defaultExpiration, cleanupInterval),
customPolicy: customPolicy,
}
}

// Set adds an item to the cache
func (c *CustomCache[V]) Set(key string, value V, expiration time.Duration) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.cache.Set(key, value, expiration)
}

// Get retrieves an item from the cache, checking both time-based and custom policies
func (c *CustomCache[V]) Get(key string) (V, bool) {
c.mutex.RLock()
0xnogo marked this conversation as resolved.
Show resolved Hide resolved
defer c.mutex.RUnlock()

var zero V
value, found := c.cache.Get(key)
if !found {
return zero, false
}

// Type assertion
typedValue, ok := value.(V)
if !ok {
return zero, false
}

// Check custom policy
if c.customPolicy != nil && c.customPolicy(typedValue) {
c.cache.Delete(key)
return zero, false
}

return typedValue, true
}

// Delete removes an item from the cache
func (c *CustomCache[V]) Delete(key string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.cache.Delete(key)
}

// Items returns all items in the cache
func (c *CustomCache[V]) Items() map[string]V {
c.mutex.RLock()
defer c.mutex.RUnlock()

items := c.cache.Items()
result := make(map[string]V)

for k, v := range items {
if value, ok := v.Object.(V); ok {
result[k] = value
}
}

return result
}

// Flush removes all items from the cache
func (c *CustomCache[V]) Flush() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.cache.Flush()
}
164 changes: 164 additions & 0 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package cache

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestCustomCache(t *testing.T) {
t.Run("basic operations without custom policy", func(t *testing.T) {
cache := NewCustomCache[int](5*time.Minute, 10*time.Minute, nil)

// Test Set and Get
cache.Set("test1", 100, NoExpiration)
value, found := cache.Get("test1")
assert.True(t, found)
assert.Equal(t, 100, value)

// Test non-existent key
_, found = cache.Get("nonexistent")
assert.False(t, found)

// Test Delete
cache.Delete("test1")
_, found = cache.Get("test1")
assert.False(t, found)
})

t.Run("custom policy eviction", func(t *testing.T) {
isEven := func(v int) bool {
return v%2 == 0
}
cache := NewCustomCache[int](5*time.Minute, 10*time.Minute, isEven)

// Even number should be evicted
cache.Set("even", 2, NoExpiration)
_, found := cache.Get("even")
assert.False(t, found)

// Odd number should remain
cache.Set("odd", 3, NoExpiration)
value, found := cache.Get("odd")
assert.True(t, found)
assert.Equal(t, 3, value)
})

t.Run("time based expiration", func(t *testing.T) {
cache := NewCustomCache[string](1*time.Second, 1*time.Second, nil)

cache.Set("key", "value", 100*time.Millisecond)

// Should exist initially
value, found := cache.Get("key")
assert.True(t, found)
assert.Equal(t, "value", value)

// Should expire
time.Sleep(200 * time.Millisecond)
_, found = cache.Get("key")
assert.False(t, found)
})

t.Run("items retrieval", func(t *testing.T) {
cache := NewCustomCache[int](5*time.Minute, 10*time.Minute, nil)

cache.Set("one", 1, NoExpiration)
cache.Set("two", 2, NoExpiration)

items := cache.Items()
assert.Len(t, items, 2)
assert.Equal(t, 1, items["one"])
assert.Equal(t, 2, items["two"])
})

t.Run("flush operation", func(t *testing.T) {
cache := NewCustomCache[int](5*time.Minute, 10*time.Minute, nil)

cache.Set("one", 1, NoExpiration)
cache.Set("two", 2, NoExpiration)

cache.Flush()
items := cache.Items()
assert.Len(t, items, 0)
})

t.Run("type safety", func(t *testing.T) {
cache := NewCustomCache[int](5*time.Minute, 10*time.Minute, nil)

// Set with correct type
cache.Set("good", 123, NoExpiration)

// Simulate wrong type in underlying cache
cache.cache.Set("bad", "not an int", NoExpiration)

// Good type should work
value, found := cache.Get("good")
assert.True(t, found)
assert.Equal(t, 123, value)

// Bad type should fail safely
_, found = cache.Get("bad")
assert.False(t, found)
})

t.Run("concurrent access", func(t *testing.T) {
cache := NewCustomCache[int](5*time.Minute, 10*time.Minute, nil)

// Run multiple goroutines accessing the cache
done := make(chan bool)
for i := 0; i < 10; i++ {
go func(val int) {
cache.Set("key", val, NoExpiration)
_, _ = cache.Get("key")
done <- true
}(i)
}

// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}

// Should have a value at the end
_, found := cache.Get("key")
assert.True(t, found)
})

t.Run("complex types", func(t *testing.T) {
type ComplexType struct {
ID int
Name string
}

cache := NewCustomCache[ComplexType](5*time.Minute, 10*time.Minute, nil)

value := ComplexType{ID: 1, Name: "test"}
cache.Set("complex", value, NoExpiration)

retrieved, found := cache.Get("complex")
assert.True(t, found)
assert.Equal(t, value, retrieved)
})

t.Run("custom policy with nil value", func(t *testing.T) {
isNil := func(v *string) bool {
return v == nil
}
cache := NewCustomCache[*string](5*time.Minute, 10*time.Minute, isNil)

str := "test"
cache.Set("nonnil", &str, NoExpiration)
cache.Set("nil", nil, NoExpiration)

// Non-nil value should remain
value, found := cache.Get("nonnil")
assert.True(t, found)
assert.Equal(t, &str, value)

// Nil value should be evicted
_, found = cache.Get("nil")
assert.False(t, found)
})
}
12 changes: 12 additions & 0 deletions internal/cache/keys/keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package cachekeys

import (
"fmt"

"github.com/smartcontractkit/chainlink-ccip/pkg/types/ccipocr3"
)

// TokenDecimals creates a cache key for token decimals
func TokenDecimals(token ccipocr3.UnknownEncodedAddress, address string) string {
return fmt.Sprintf("token-decimals:%s:%s", token, address)
}
56 changes: 56 additions & 0 deletions internal/cache/keys/keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cachekeys

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/smartcontractkit/chainlink-ccip/pkg/types/ccipocr3"
)

func TestTokenDecimals(t *testing.T) {
testCases := []struct {
name string
token ccipocr3.UnknownEncodedAddress
address string
expectedKey string
}{
{
name: "basic key generation",
token: ccipocr3.UnknownEncodedAddress("0x1234"),
address: "0xabcd",
expectedKey: "token-decimals:0x1234:0xabcd",
},
{
name: "empty token address",
token: ccipocr3.UnknownEncodedAddress(""),
address: "0xabcd",
expectedKey: "token-decimals::0xabcd",
},
{
name: "empty contract address",
token: ccipocr3.UnknownEncodedAddress("0x1234"),
address: "",
expectedKey: "token-decimals:0x1234:",
},
{
name: "both addresses empty",
token: ccipocr3.UnknownEncodedAddress(""),
address: "",
expectedKey: "token-decimals::",
},
{
name: "long addresses",
token: ccipocr3.UnknownEncodedAddress("0x1234567890abcdef1234567890abcdef12345678"),
address: "0xfedcba0987654321fedcba0987654321fedcba09",
expectedKey: "token-decimals:0x1234567890abcdef1234567890abcdef12345678:0xfedcba0987654321fedcba0987654321fedcba09",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
key := TokenDecimals(tc.token, tc.address)
assert.Equal(t, tc.expectedKey, key)
})
}
}
Loading