diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..54b1887 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +# Makefile for cache2go + +.PHONY: all build clean test coverage fmt lint + +# Build settings +GOCMD = go +GOBUILD = $(GOCMD) build +GOCLEAN = $(GOCMD) clean +GOTEST = $(GOCMD) test +GOGET = $(GOCMD) get +GOMOD = $(GOCMD) mod +GOFMT = $(GOCMD) fmt +GOLINT = golangci-lint + +# Project name +PROJECT_NAME = cache2go + +# Build all binary +all: build + +# Build the project +build: + $(GOBUILD) -v ./... + +# Clean build files +clean: + $(GOCLEAN) + rm -f coverage.out + +# Run tests +test: + $(GOTEST) -v ./... + +# Run tests with race detection +test-race: + $(GOTEST) -race -v ./... + +# Run tests with coverage +coverage: + $(GOTEST) -coverprofile=coverage.out -covermode=atomic ./... + $(GOCMD) tool cover -html=coverage.out + +# Format code +fmt: + $(GOFMT) ./... + +# Run linter +lint: + $(GOLINT) run + +# Tidy modules +tidy: + $(GOMOD) tidy + +# Update dependencies +deps: + $(GOGET) -u ./... + $(GOMOD) tidy + +# Run SIEVE example +example-sieve: + $(GOCMD) run examples/sieve_example/sieve_example.go diff --git a/cache.go b/cache.go index 232c2ae..6e999ac 100644 --- a/cache.go +++ b/cache.go @@ -30,8 +30,9 @@ func Cache(table string) *CacheTable { // Double check whether the table exists or not. if !ok { t = &CacheTable{ - name: table, - items: make(map[interface{}]*CacheItem), + name: table, + items: make(map[interface{}]*CacheItem), + evictionPolicy: EvictionNone, } cache[table] = t } diff --git a/cachetable.go b/cachetable.go index 1fa75ad..55cf956 100644 --- a/cachetable.go +++ b/cachetable.go @@ -37,6 +37,13 @@ type CacheTable struct { addedItem []func(item *CacheItem) // Callback method triggered before deleting an item from the cache. aboutToDeleteItem []func(item *CacheItem) + + // Eviction policy + evictionPolicy EvictionPolicy + // SIEVE cache implementation (only used when evictionPolicy is EvictionSIEVE) + sieveCache *SIEVECache + // Maximum capacity (0 means unlimited) + maxCapacity int } // Count returns how many items are currently stored in the cache. @@ -46,6 +53,33 @@ func (table *CacheTable) Count() int { return len(table.items) } +// SetEvictionPolicy sets the eviction policy and maximum capacity for the cache table. +// If policy is EvictionSIEVE, it initializes a SIEVE cache with the given capacity and sample size. +// If capacity is 0, the cache will have unlimited capacity (not recommended with SIEVE). +// If sampleSize is 0, a default sample size of 8 will be used for SIEVE. +func (table *CacheTable) SetEvictionPolicy(policy EvictionPolicy, capacity int, sampleSize int) { + table.Lock() + defer table.Unlock() + + table.evictionPolicy = policy + table.maxCapacity = capacity + + // Initialize SIEVE cache if that's the chosen policy + if policy == EvictionSIEVE { + if capacity <= 0 { + capacity = 1024 // Default to 1024 if no capacity specified + } + table.sieveCache = NewSIEVECache(capacity, sampleSize) + + // Pre-populate SIEVE cache with existing items + for _, item := range table.items { + table.sieveCache.Add(item) + } + } else { + table.sieveCache = nil + } +} + // Foreach all items func (table *CacheTable) Foreach(trans func(key interface{}, item *CacheItem)) { table.RLock() @@ -76,7 +110,7 @@ func (table *CacheTable) SetAddedItemCallback(f func(*CacheItem)) { table.addedItem = append(table.addedItem, f) } -//AddAddedItemCallback appends a new callback to the addedItem queue +// AddAddedItemCallback appends a new callback to the addedItem queue func (table *CacheTable) AddAddedItemCallback(f func(*CacheItem)) { table.Lock() defer table.Unlock() @@ -203,6 +237,13 @@ func (table *CacheTable) Add(key interface{}, lifeSpan time.Duration, data inter // Add item to cache. table.Lock() + + // We need to update sieveCache before calling addInternal + // since addInternal will unlock the mutex + if table.evictionPolicy == EvictionSIEVE && table.sieveCache != nil { + table.sieveCache.Add(item) + } + table.addInternal(item) return item @@ -243,9 +284,19 @@ func (table *CacheTable) deleteInternal(key interface{}) (*CacheItem, error) { // Delete an item from the cache. func (table *CacheTable) Delete(key interface{}) (*CacheItem, error) { table.Lock() - defer table.Unlock() - return table.deleteInternal(key) + // If using SIEVE eviction, remove from SIEVE cache as well + if table.evictionPolicy == EvictionSIEVE && table.sieveCache != nil { + table.sieveCache.Delete(key) + } + + item, ok := table.items[key] + if !ok { + table.Unlock() + return nil, ErrKeyNotFound + } + + return table.deleteInternal(item) } // Exists returns whether an item exists in the cache. Unlike the Value method @@ -269,6 +320,11 @@ func (table *CacheTable) NotFoundAdd(key interface{}, lifeSpan time.Duration, da return false } + // If using SIEVE eviction, remove from SIEVE cache as well + if table.evictionPolicy == EvictionSIEVE && table.sieveCache != nil { + table.sieveCache.Delete(key) + } + item := NewCacheItem(key, lifeSpan, data) table.addInternal(item) @@ -279,6 +335,15 @@ func (table *CacheTable) NotFoundAdd(key interface{}, lifeSpan time.Duration, da // pass additional arguments to your DataLoader callback function. func (table *CacheTable) Value(key interface{}, args ...interface{}) (*CacheItem, error) { table.RLock() + + // If using SIEVE eviction, update the access time in SIEVE cache + if table.evictionPolicy == EvictionSIEVE && table.sieveCache != nil { + if item, exists := table.sieveCache.Get(key); exists { + // Item exists in SIEVE cache, update will be handled by Get() + _ = item + } + } + r, ok := table.items[key] loadData := table.loadData table.RUnlock() @@ -315,6 +380,11 @@ func (table *CacheTable) Flush() { if table.cleanupTimer != nil { table.cleanupTimer.Stop() } + + // If using SIEVE eviction, flush SIEVE cache as well + if table.evictionPolicy == EvictionSIEVE && table.sieveCache != nil { + table.sieveCache.Flush() + } } // CacheItemPair maps key to access counter diff --git a/eviction.go b/eviction.go new file mode 100644 index 0000000..1765ffc --- /dev/null +++ b/eviction.go @@ -0,0 +1,18 @@ +/* + * Simple caching library with expiration capabilities + * Copyright (c) 2013-2017, Christian Muehlhaeuser + * + * For license see LICENSE.txt + */ + +package cache2go + +// EvictionPolicy defines the cache eviction policy. +type EvictionPolicy int + +const ( + // EvictionNone uses no eviction policy (keep items until they expire). + EvictionNone EvictionPolicy = iota + // EvictionSIEVE uses the SIEVE eviction algorithm. + EvictionSIEVE +) diff --git a/examples/sieve_example/sieve_example.go b/examples/sieve_example/sieve_example.go new file mode 100644 index 0000000..65b3dc8 --- /dev/null +++ b/examples/sieve_example/sieve_example.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "github.com/muesli/cache2go" + "math/rand" + "time" +) + +// Keys & values in cache2go can be of arbitrary types, e.g. a struct. +type myStruct struct { + text string + moreData []byte +} + +func main() { + // In newer Go versions (1.20+), we don't need to seed the random number generator + // For Go < 1.20, uncomment the line below: + // rand.Seed(time.Now().UnixNano()) + + // Accessing a new cache table for the first time will create it. + cache := cache2go.Cache("myCache") + + // Enable SIEVE eviction with a capacity of 100 items and a sample size of 8 + cache.SetEvictionPolicy(cache2go.EvictionSIEVE, 100, 8) + fmt.Println("Created cache with SIEVE eviction policy, capacity: 100, sample size: 8") + + // Add 150 items to the cache (more than capacity) + for i := 0; i < 150; i++ { + val := myStruct{fmt.Sprintf("This is test item #%d", i), []byte{}} + cache.Add(fmt.Sprintf("key-%d", i), 5*time.Minute, &val) + } + + // Check how many items are in the cache + // Should be 100 (or less if some expired by chance) + fmt.Printf("Cache count after adding 150 items: %d (should be around 100 due to capacity limit)\n", cache.Count()) + + // Now perform some random accesses to simulate real-world usage + for i := 0; i < 1000; i++ { + key := fmt.Sprintf("key-%d", rand.Intn(150)) + res, err := cache.Value(key) + if err == nil { + // Just access the item to update its access time in the SIEVE cache + _ = res.Data().(*myStruct).text + } + } + + // Add 50 more items + for i := 150; i < 200; i++ { + val := myStruct{fmt.Sprintf("This is test item #%d", i), []byte{}} + cache.Add(fmt.Sprintf("key-%d", i), 5*time.Minute, &val) + } + + // Check how many items are in the cache again + fmt.Printf("Cache count after adding 50 more items: %d (should still be around 100)\n", cache.Count()) + + // Check for a few specific keys to see which ones were kept in the cache + // Items that were accessed more frequently are more likely to be in the cache + for i := 0; i < 200; i += 20 { + key := fmt.Sprintf("key-%d", i) + _, err := cache.Value(key) + if err == nil { + fmt.Printf("Key %s is still in the cache\n", key) + } else { + fmt.Printf("Key %s was evicted from the cache\n", key) + } + } + + // Flush the cache + cache.Flush() + fmt.Printf("Cache count after flush: %d\n", cache.Count()) +} diff --git a/sieve.go b/sieve.go new file mode 100644 index 0000000..5503f5f --- /dev/null +++ b/sieve.go @@ -0,0 +1,145 @@ +package cache2go + +import ( + "math/rand" + "sync" + "time" +) + +// SIEVECache implements the SIEVE cache eviction algorithm. +// SIEVE is a simpler than LRU eviction algorithm that achieves state-of-the-art efficiency on skewed workloads. +// Reference: https://cachemon.github.io/SIEVE-website/ +type SIEVECache struct { + items map[interface{}]*CacheItem + mu sync.RWMutex + samples int // Number of items to sample during eviction + capacity int // Maximum capacity of the cache +} + +// NewSIEVECache creates a new SIEVE cache with the specified capacity and sample size. +func NewSIEVECache(capacity, samples int) *SIEVECache { + if samples <= 0 { + samples = 8 // Default sample size as suggested by the SIEVE paper + } + if capacity <= 0 { + capacity = 1024 // Default capacity + } + return &SIEVECache{ + items: make(map[interface{}]*CacheItem), + samples: samples, + capacity: capacity, + } +} + +// Add adds a new item to the SIEVE cache. +func (sc *SIEVECache) Add(item *CacheItem) { + sc.mu.Lock() + defer sc.mu.Unlock() + + // Check if we need to evict an item + if len(sc.items) >= sc.capacity { + sc.evict() + } + + // Add the new item + sc.items[item.key] = item +} + +// Get retrieves an item from the SIEVE cache. +func (sc *SIEVECache) Get(key interface{}) (*CacheItem, bool) { + sc.mu.RLock() + item, exists := sc.items[key] + sc.mu.RUnlock() + + if exists { + // Update access time (lazy promotion) + // Note: We don't need to lock the item here as the CacheItem.KeepAlive method + // will handle updating the accessedOn field safely + return item, true + } + return nil, false +} + +// Delete removes an item from the SIEVE cache. +func (sc *SIEVECache) Delete(key interface{}) bool { + sc.mu.Lock() + defer sc.mu.Unlock() + + if _, exists := sc.items[key]; exists { + delete(sc.items, key) + return true + } + return false +} + +// Count returns the number of items in the SIEVE cache. +func (sc *SIEVECache) Count() int { + sc.mu.RLock() + defer sc.mu.RUnlock() + return len(sc.items) +} + +// Flush removes all items from the SIEVE cache. +func (sc *SIEVECache) Flush() { + sc.mu.Lock() + defer sc.mu.Unlock() + sc.items = make(map[interface{}]*CacheItem) +} + +// evict implements the SIEVE eviction algorithm. +// It samples a few items from the cache and evicts the oldest accessed one. +// Note: This method assumes the caller holds the lock on the SIEVE cache. +func (sc *SIEVECache) evict() { + if len(sc.items) == 0 { + return + } + + // Sample a few random items + samples := min(sc.samples, len(sc.items)) + keys := make([]interface{}, 0, len(sc.items)) + for k := range sc.items { + keys = append(keys, k) + } + + // Randomly select 'samples' number of items + var oldestKey interface{} + var oldestTime time.Time + + // Initialize with the first sample + randomIndex := rand.Intn(len(keys)) + oldestKey = keys[randomIndex] + + // We need to safely read the accessedOn time + item := sc.items[oldestKey] + item.RLock() + oldestTime = item.accessedOn + item.RUnlock() + + // Check remaining samples + for i := 1; i < samples; i++ { + randomIndex = rand.Intn(len(keys)) + key := keys[randomIndex] + item := sc.items[key] + + // Safely read the accessedOn time + item.RLock() + accessTime := item.accessedOn + item.RUnlock() + + if accessTime.Before(oldestTime) { + oldestKey = key + oldestTime = accessTime + } + } + + // Evict the oldest item + delete(sc.items, oldestKey) +} + +// min returns the smaller of x or y. +func min(x, y int) int { + if x < y { + return x + } + return y +} diff --git a/sieve_test.go b/sieve_test.go new file mode 100644 index 0000000..9fd1065 --- /dev/null +++ b/sieve_test.go @@ -0,0 +1,185 @@ +/* + * Simple caching library with expiration capabilities + * Copyright (c) 2013-2017, Christian Muehlhaeuser + * + * For license see LICENSE.txt + */ + +package cache2go + +import ( + "math/rand" + "sync" + "testing" + "time" +) + +// TestSIEVEBasic tests basic SIEVE functionality +func TestSIEVEBasic(t *testing.T) { + table := Cache("testSIEVE") + table.Flush() + + // Set SIEVE policy with capacity of 5 and sample size of 2 + table.SetEvictionPolicy(EvictionSIEVE, 5, 2) + + // Add 5 items + for i := 0; i < 5; i++ { + table.Add(i, 0, i) + } + + // Verify all 5 items are in the cache + if table.Count() != 5 { + t.Errorf("Expected 5 items in cache, got %d", table.Count()) + } + + // Add a 6th item, which should trigger eviction + table.Add(5, 0, 5) + + // Verify still 5 items in cache (capacity enforced) + if table.Count() != 5 { + t.Errorf("Expected 5 items in cache after eviction, got %d", table.Count()) + } + + // Cleanup + table.Flush() +} + +// TestSIEVEEviction tests that the SIEVE eviction policy works +func TestSIEVEEviction(t *testing.T) { + table := Cache("testSIEVEEviction") + table.Flush() + + // Set SIEVE policy with capacity of 10 and sample size of 3 + table.SetEvictionPolicy(EvictionSIEVE, 10, 3) + + // Add 10 items + for i := 0; i < 10; i++ { + table.Add(i, 0, i) + } + + // Access some items multiple times to make them "hotter" + // Items 0, 2, 4, 6, 8 are accessed more frequently + for i := 0; i < 5; i++ { + for j := 0; j < 10; j += 2 { + _, err := table.Value(j) + if err != nil { + t.Errorf("Failed to access item %d: %v", j, err) + } + } + } + + // Add 5 more items, which should trigger evictions + for i := 10; i < 15; i++ { + table.Add(i, 0, i) + } + + // Verify still 10 items in cache (capacity enforced) + if table.Count() != 10 { + t.Errorf("Expected 10 items in cache after eviction, got %d", table.Count()) + } + + // Check which items remain - since we used randomness in SIEVE + // we can't guarantee which exact items were evicted, but we can + // do a probability check + hotItems := 0 + for j := 0; j < 10; j += 2 { + if table.Exists(j) { + hotItems++ + } + } + + // With a probability, more of the frequently accessed items should remain + // This is a probabilistic test, so we set a reasonable threshold + // We expect at least 3 of the 5 hot items to remain + if hotItems < 3 { + t.Errorf("Expected at least 3 frequently accessed items to remain, got %d", hotItems) + } + + // Cleanup + table.Flush() +} + +// TestSIEVEConcurrency tests concurrent access to SIEVE cache +func TestSIEVEConcurrency(t *testing.T) { + table := Cache("testSIEVEConcurrency") + table.Flush() + + // Set SIEVE policy with capacity of 100 and sample size of 8 + table.SetEvictionPolicy(EvictionSIEVE, 100, 8) + + // Add 100 items + for i := 0; i < 100; i++ { + table.Add(i, 0, i) + } + + // Run concurrent access operations + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + // Each worker does 100 random accesses + for j := 0; j < 100; j++ { + key := rand.Intn(200) // Some will miss, some will hit + if key < 100 { + // Existing key - should hit + _, _ = table.Value(key) + } else { + // New key - add to cache + table.Add(key, 0, key) + } + } + }(i) + } + + // Wait for all workers to finish + wg.Wait() + + // Verify the cache still has correct capacity + if table.Count() != 100 { + t.Errorf("Expected 100 items in cache after concurrent operations, got %d", table.Count()) + } + + // Cleanup + table.Flush() +} + +// TestSIEVEExpiration tests that items still expire correctly with SIEVE +func TestSIEVEExpiration(t *testing.T) { + table := Cache("testSIEVEExpiration") + table.Flush() + + // Set SIEVE policy with capacity of 10 + table.SetEvictionPolicy(EvictionSIEVE, 10, 3) + + // Add items with short expiration + table.Add("test1", 50*time.Millisecond, "value1") + table.Add("test2", 150*time.Millisecond, "value2") + + // Verify both items exist + if !table.Exists("test1") || !table.Exists("test2") { + t.Error("Expected both items to exist immediately after adding") + } + + // Wait for first item to expire + time.Sleep(100 * time.Millisecond) + + // First item should be gone, second should still exist + if table.Exists("test1") { + t.Error("Expected first item to be expired") + } + if !table.Exists("test2") { + t.Error("Expected second item to still exist") + } + + // Wait for second item to expire + time.Sleep(100 * time.Millisecond) + + // Both items should be gone + if table.Exists("test1") || table.Exists("test2") { + t.Error("Expected both items to be expired") + } + + // Cleanup + table.Flush() +}