Skip to content

Commit

Permalink
adding timestamp to item
Browse files Browse the repository at this point in the history
  • Loading branch information
0xnogo committed Dec 19, 2024
1 parent 10392bc commit bddf7f4
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 107 deletions.
121 changes: 77 additions & 44 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,100 @@ import (
"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.
// all basic cache operations. Keys are strings, and values can be of any type. Each cached
// value stores its insertion timestamp, allowing for time-based validation in custom policies.
//
// Example usage with contract reader:
// type Object {
// Value interface{}
// }
//
// type Event struct {
// Timestamp int64
// Data string
// }
//
// Example usage:
// type ContractReader interface {
// QueryEvents(ctx context.Context, filter QueryFilter) ([]Event, error)
// }
//
// // 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
// reader := NewContractReader()
//
// // Create cache with contract reader in closure
// cache := NewCustomCache[Object](
// 5*time.Minute, // Default expiration
// 10*time.Minute, // Cleanup interval
// func(o Event, storedAt time.Time) bool {
// ctx := context.Background()
// filter := QueryFilter{
// FromTimestamp: storedAt.Unix(),
// Confidence: Finalized,
// }
//
// // Query for any events after our cache insertion time
// newEvents, err := reader.QueryEvents(ctx, filter)
// if err != nil {
// return false // Keep cache on error
// }
//
// // Evict if new events exist after our cache time
// return len(newEvents) > 0
// },
// )
//
// // Use NoExpiration for items that shouldn't expire
// cache.Set("key", 42, cache.NoExpiration)
// // Cache an object
// o := Object{Data: "..."}
// cache.Set("key", o, 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
// // Later: event will be evicted if newer ones exist on chain
// o, found := cache.Get("key")
//
// The cache ensures data freshness through:
// - Automatic time-based expiration from go-cache
// - Custom eviction policies with access to storage timestamps
// - Thread-safe operations for concurrent access
// - Type safety through Go generics

const (
NoExpiration = cache.NoExpiration
)

// timestampedValue wraps a value with its storage timestamp
type timestampedValue[V any] struct {
Value V
StoredAt time.Time
}

type CustomCache[V any] struct {
cache *cache.Cache
customPolicy func(V) bool
*cache.Cache
customPolicy func(V, time.Time) bool // Updated to include storage time
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,
customPolicy func(V, time.Time) bool,
) *CustomCache[V] {
return &CustomCache[V]{
cache: cache.New(defaultExpiration, cleanupInterval),
Cache: cache.New(defaultExpiration, cleanupInterval),
customPolicy: customPolicy,
}
}

// Set adds an item to the cache
// Set adds an item to the cache with current timestamp
func (c *CustomCache[V]) Set(key string, value V, expiration time.Duration) {
c.cache.Set(key, value, expiration)
wrapped := timestampedValue[V]{
Value: value,
StoredAt: time.Now(),
}
c.Cache.Set(key, wrapped, expiration)
}

// Get retrieves an item from the cache, checking both time-based and custom policies
Expand All @@ -66,49 +109,39 @@ func (c *CustomCache[V]) Get(key string) (V, bool) {
defer c.mutex.Unlock()

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

// Type assertion
typedValue, ok := value.(V)
// Type assertion for timestamped value
wrapped, ok := value.(timestampedValue[V])
if !ok {
return zero, false
}

// Check custom policy
if c.customPolicy != nil && c.customPolicy(typedValue) {
c.cache.Delete(key)
// Check custom policy with timestamp
if c.customPolicy != nil && c.customPolicy(wrapped.Value, wrapped.StoredAt) {
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.cache.Delete(key)
return wrapped.Value, true
}

// 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()
items := c.Cache.Items()
result := make(map[string]V)

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

return result
}

// Flush removes all items from the cache
func (c *CustomCache[V]) Flush() {
c.cache.Flush()
}
122 changes: 59 additions & 63 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,29 @@ func TestCustomCache(t *testing.T) {
assert.False(t, found)
})

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

// 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")
// Value stored now should not be evicted
cache.Set("fresh", 1, NoExpiration)
value, found := cache.Get("fresh")
assert.True(t, found)
assert.Equal(t, 3, value)
assert.Equal(t, 1, value)

// Simulate old value by manipulating timestamp
oldValue := timestampedValue[int]{
Value: 2,
StoredAt: now.Add(-1 * time.Hour),
}
cache.Cache.Set("stale", oldValue, NoExpiration)

// Stale value should be evicted
_, found = cache.Get("stale")
assert.False(t, found)
})

t.Run("time based expiration", func(t *testing.T) {
Expand Down Expand Up @@ -73,37 +80,7 @@ func TestCustomCache(t *testing.T) {
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) {
t.Run("concurrent access with timestamps", func(t *testing.T) {
cache := NewCustomCache[int](5*time.Minute, 10*time.Minute, nil)

// Run multiple goroutines accessing the cache
Expand All @@ -126,39 +103,58 @@ func TestCustomCache(t *testing.T) {
assert.True(t, found)
})

t.Run("complex types", func(t *testing.T) {
t.Run("complex types with timestamp eviction", func(t *testing.T) {
type ComplexType struct {
ID int
Name string
ID int
Name string
Timestamp time.Time
}

cache := NewCustomCache[ComplexType](5*time.Minute, 10*time.Minute, nil)
threshold := time.Now()
cache := NewCustomCache[ComplexType](
5*time.Minute,
10*time.Minute,
func(v ComplexType, storedAt time.Time) bool {
return storedAt.Before(threshold)
},
)

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

// Fresh value should not be evicted
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
// Simulate old value
oldValue := timestampedValue[ComplexType]{
Value: ComplexType{ID: 2, Name: "old"},
StoredAt: threshold.Add(-1 * time.Hour),
}
cache := NewCustomCache[*string](5*time.Minute, 10*time.Minute, isNil)
cache.Cache.Set("old", oldValue, NoExpiration)

// Old value should be evicted
_, found = cache.Get("old")
assert.False(t, found)
})

str := "test"
cache.Set("nonnil", &str, NoExpiration)
cache.Set("nil", nil, NoExpiration)
t.Run("correct timestamp storage", func(t *testing.T) {
cache := NewCustomCache[string](5*time.Minute, 10*time.Minute, nil)

// Non-nil value should remain
value, found := cache.Get("nonnil")
before := time.Now()
cache.Set("key", "value", NoExpiration)
after := time.Now()

// Get the raw timestamped value
raw, found := cache.Cache.Get("key")
assert.True(t, found)
assert.Equal(t, &str, value)

// Nil value should be evicted
_, found = cache.Get("nil")
assert.False(t, found)
wrapped, ok := raw.(timestampedValue[string])
assert.True(t, ok)

// StoredAt should be between before and after
assert.True(t, wrapped.StoredAt.After(before) || wrapped.StoredAt.Equal(before))
assert.True(t, wrapped.StoredAt.Before(after) || wrapped.StoredAt.Equal(after))
})
}

0 comments on commit bddf7f4

Please sign in to comment.