diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..fab5045 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +language: go + +go: + - 1.12 + - 1.11 +git: + depth: 1 + +install: + - go install -race std + - go get golang.org/x/tools/cmd/cover + - go get golang.org/x/lint/golint + - go get github.com/tools/godep + - export PATH=$HOME/gopath/bin:$PATH + - godep restore + +script: + - golint . + - go test ./... -race -count=1 -timeout=1m -run . + - go test -cover ./... + - go test -run=Bench.* -bench=. -benchmem \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b3b587d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Rene Kroon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..0a5b5f2 --- /dev/null +++ b/Readme.md @@ -0,0 +1,61 @@ +## TTLCache - an in-memory cache with expiration + +TTLCache is a simple key/value cache in golang with the following functions: + +1. Thread-safe +2. Individual expiring time or global expiring time, you can choose +3. Auto-Extending expiration on `Get` -or- DNS style TTL, see `SkipTtlExtensionOnHit(bool)` +4. Fast and memory efficient +5. Can trigger callback on key expiration + +[![Build Status](https://travis-ci.org/ReneKroon/ttlcache.svg?branch=master)](https://travis-ci.org/ReneKroon/ttlcache) + +#### Usage +```go +import ( + "time" + "fmt" + + "github.com/ReneKroon/ttlcache" +) + +func main () { + newItemCallback := func(key string, value interface{}) { + fmt.Printf("New key(%s) added\n", key) + } + checkExpirationCallback := func(key string, value interface{}) bool { + if key == "key1" { + // if the key equals "key1", the value + // will not be allowed to expire + return false + } + // all other values are allowed to expire + return true + } + expirationCallback := func(key string, value interface{}) { + fmt.Printf("This key(%s) has expired\n", key) + } + + cache := ttlcache.NewCache() + cache.SetTTL(time.Duration(10 * time.Second)) + cache.SetExpirationCallback(expirationCallback) + + cache.Set("key", "value") + cache.SetWithTTL("keyWithTTL", "value", 10 * time.Second) + + value, exists := cache.Get("key") + count := cache.Count() + result := cache.Remove("key") +} +``` + +#### Original Project + +TTLCache was forked from [wunderlist/ttlcache](https://github.com/wunderlist/ttlcache) to add extra functions not avaiable in the original scope. +The main differences are: + +1. A item can store any kind of object, previously, only strings could be saved +2. Optionally, you can add callbacks to: check if a value should expire, be notified if a value expires, and be notified when new values are added to the cache +3. The expiration can be either global or per item +4. Can exist items without expiration time +5. Expirations and callbacks are realtime. Don't have a pooling time to check anymore, now it's done with a heap. diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..ede6abf --- /dev/null +++ b/cache.go @@ -0,0 +1,259 @@ +package ttlcache + +import ( + "sync" + "time" +) + +// CheckExpireCallback is used as a callback for an external check on item expiration +type checkExpireCallback func(key string, value interface{}) bool + +// ExpireCallback is used as a callback on item expiration or when notifying of an item new to the cache +type expireCallback func(key string, value interface{}) + +// Cache is a synchronized map of items that can auto-expire once stale +type Cache struct { + mutex sync.RWMutex + ttl time.Duration + items map[string]*item + expireCallback expireCallback + checkExpireCallback checkExpireCallback + newItemCallback expireCallback + priorityQueue *priorityQueue + expirationNotification chan bool + expirationTime time.Time + skipTTLExtension bool +} + +func (cache *Cache) getItem(key string) (*item, bool) { + cache.mutex.RLock() + + item, exists := cache.items[key] + if !exists || item.expired() { + cache.mutex.RUnlock() + return nil, false + } + + if item.ttl >= 0 && (item.ttl > 0 || cache.ttl > 0) { + if cache.ttl > 0 && item.ttl == 0 { + item.ttl = cache.ttl + } + + if !cache.skipTTLExtension { + item.touch() + } + cache.priorityQueue.update(item) + } + + cache.mutex.RUnlock() + cache.expirationNotificationTrigger(item) + return item, exists +} + +func (cache *Cache) startExpirationProcessing() { + for { + var sleepTime time.Duration + cache.mutex.Lock() + if cache.priorityQueue.Len() > 0 { + sleepTime = time.Until(cache.priorityQueue.items[0].expireAt) + if sleepTime < 0 && cache.priorityQueue.items[0].expireAt.IsZero() { + sleepTime = time.Hour + } else if sleepTime < 0 { + sleepTime = time.Microsecond + } + if cache.ttl > 0 { + sleepTime = min(sleepTime, cache.ttl) + } + + } else if cache.ttl > 0 { + sleepTime = cache.ttl + } else { + sleepTime = time.Hour + } + + cache.expirationTime = time.Now().Add(sleepTime) + cache.mutex.Unlock() + + timer := time.NewTimer(sleepTime) + select { + case <-timer.C: + timer.Stop() + cache.mutex.Lock() + if cache.priorityQueue.Len() == 0 { + cache.mutex.Unlock() + continue + } + + // index will only be advanced if the current entry will not be evicted + i := 0 + for item := cache.priorityQueue.items[i]; item.expired(); item = cache.priorityQueue.items[i] { + + if cache.checkExpireCallback != nil { + if !cache.checkExpireCallback(item.key, item.data) { + item.touch() + cache.priorityQueue.update(item) + i++ + if i == cache.priorityQueue.Len() { + break + } + continue + } + } + + cache.priorityQueue.remove(item) + delete(cache.items, item.key) + if cache.expireCallback != nil { + go cache.expireCallback(item.key, item.data) + } + if cache.priorityQueue.Len() == 0 { + goto done + } + } + done: + cache.mutex.Unlock() + + case <-cache.expirationNotification: + timer.Stop() + continue + } + } +} + +func (cache *Cache) expirationNotificationTrigger(item *item) { + cache.mutex.Lock() + if cache.expirationTime.After(time.Now().Add(item.ttl)) { + cache.mutex.Unlock() + cache.expirationNotification <- true + } else { + cache.mutex.Unlock() + } +} + +// Set is a thread-safe way to add new items to the map +func (cache *Cache) Set(key string, data interface{}) { + cache.SetWithTTL(key, data, ItemExpireWithGlobalTTL) +} + +// SetWithTTL is a thread-safe way to add new items to the map with individual ttl +func (cache *Cache) SetWithTTL(key string, data interface{}, ttl time.Duration) { + item, exists := cache.getItem(key) + cache.mutex.Lock() + + if exists { + item.data = data + item.ttl = ttl + } else { + item = newItem(key, data, ttl) + cache.items[key] = item + } + + if item.ttl >= 0 && (item.ttl > 0 || cache.ttl > 0) { + if cache.ttl > 0 && item.ttl == 0 { + item.ttl = cache.ttl + } + item.touch() + } + + if exists { + cache.priorityQueue.update(item) + } else { + cache.priorityQueue.push(item) + } + + cache.mutex.Unlock() + if !exists && cache.newItemCallback != nil { + cache.newItemCallback(key, data) + } + cache.expirationNotification <- true +} + +// Get is a thread-safe way to lookup items +// Every lookup, also touches the item, hence extending it's life +func (cache *Cache) Get(key string) (interface{}, bool) { + item, exists := cache.getItem(key) + if exists { + cache.mutex.RLock() + defer cache.mutex.RUnlock() + return item.data, true + } + return nil, false +} + +func (cache *Cache) Remove(key string) bool { + cache.mutex.Lock() + object, exists := cache.items[key] + if !exists { + cache.mutex.Unlock() + return false + } + delete(cache.items, object.key) + cache.priorityQueue.remove(object) + cache.mutex.Unlock() + + return true +} + +// Count returns the number of items in the cache +func (cache *Cache) Count() int { + cache.mutex.RLock() + length := len(cache.items) + cache.mutex.RUnlock() + return length +} + +func (cache *Cache) SetTTL(ttl time.Duration) { + cache.mutex.Lock() + cache.ttl = ttl + cache.mutex.Unlock() + cache.expirationNotification <- true +} + +// SetExpirationCallback sets a callback that will be called when an item expires +func (cache *Cache) SetExpirationCallback(callback expireCallback) { + cache.expireCallback = callback +} + +// SetCheckExpirationCallback sets a callback that will be called when an item is about to expire +// in order to allow external code to decide whether the item expires or remains for another TTL cycle +func (cache *Cache) SetCheckExpirationCallback(callback checkExpireCallback) { + cache.checkExpireCallback = callback +} + +// SetNewItemCallback sets a callback that will be called when a new item is added to the cache +func (cache *Cache) SetNewItemCallback(callback expireCallback) { + cache.newItemCallback = callback +} + +// SkipTtlExtensionOnHit allows the user to change the cache behaviour. When this flag is set to true it will +// no longer extend TTL of items when they are retrieved using Get, or when their expiration condition is evaluated +// using SetCheckExpirationCallback. +func (cache *Cache) SkipTtlExtensionOnHit(value bool) { + cache.skipTTLExtension = value +} + +// Purge will remove all entries +func (cache *Cache) Purge() { + cache.mutex.Lock() + cache.items = make(map[string]*item) + cache.priorityQueue = newPriorityQueue() + cache.mutex.Unlock() +} + +// NewCache is a helper to create instance of the Cache struct +func NewCache() *Cache { + cache := &Cache{ + items: make(map[string]*item), + priorityQueue: newPriorityQueue(), + expirationNotification: make(chan bool), + expirationTime: time.Now(), + } + go cache.startExpirationProcessing() + return cache +} + +func min(duration time.Duration, second time.Duration) time.Duration { + if duration < second { + return duration + } + return second +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..711e4c8 --- /dev/null +++ b/cache_test.go @@ -0,0 +1,372 @@ +package ttlcache + +import ( + "testing" + "time" + + "fmt" + "log" + "sync" + + "github.com/stretchr/testify/assert" +) + +// test for Feature request in issue #12 +// +func TestCache_SkipTtlExtensionOnHit(t *testing.T) { + cache := NewCache() + cache.SetTTL(time.Millisecond * 100) + + cache.SkipTtlExtensionOnHit(false) + cache.Set("test", "!") + startTime := time.Now() + for now := time.Now(); now.Before(startTime.Add(time.Second * 3)); now = time.Now() { + if _, found := cache.Get("test"); !found { + t.Errorf("Item was not found, even though it should not expire.") + } + + } + + cache.SkipTtlExtensionOnHit(true) + cache.Set("expireTest", "!") + // will loop if item does not expire + for _, found := cache.Get("expireTest"); found; _, found = cache.Get("expireTest") { + } +} + +func TestCache_SkipTtlExtensionOnHit_ForRacesAcrossGoroutines(t *testing.T) { + cache := NewCache() + cache.SetTTL(time.Minute * 1) + cache.SkipTtlExtensionOnHit(true) + + var wgSet sync.WaitGroup + var wgGet sync.WaitGroup + + n := 500 + wgSet.Add(1) + go func() { + for i := 0; i < n; i++ { + wgSet.Add(1) + + go func() { + cache.Set("test", false) + wgSet.Done() + }() + } + wgSet.Done() + }() + wgGet.Add(1) + go func() { + for i := 0; i < n; i++ { + wgGet.Add(1) + + go func() { + cache.Get("test") + wgGet.Done() + }() + } + wgGet.Done() + }() + + wgGet.Wait() + wgSet.Wait() +} + +// test github issue #14 +// Testing expiration callback would continue with the next item in list, even when it exceeds list lengths +func TestCache_SetCheckExpirationCallback(t *testing.T) { + iterated := 0 + ch := make(chan struct{}) + + cacheAD := NewCache() + cacheAD.SetTTL(time.Millisecond) + cacheAD.SetCheckExpirationCallback(func(key string, value interface{}) bool { + v := value.(*int) + log.Printf("key=%v, value=%d\n", key, *v) + iterated++ + if iterated == 1 { + // this is the breaking test case for issue #14 + return false + } + ch <- struct{}{} + return true + }) + + i := 2 + cacheAD.Set("a", &i) + + <-ch +} + +// test github issue #9 +// Due to scheduling the expected TTL of the top entry can become negative (already expired) +// This is an issue because negative TTL at the item level was interpreted as 'use global TTL' +// Which is not right when we become negative due to scheduling. +// This test could use improvement as it's not requiring a lot of time to trigger. +func TestCache_SetExpirationCallback(t *testing.T) { + + type A struct { + } + + // Setup the TTL cache + cache := NewCache() + cache.SetTTL(time.Second * 1) + cache.SetExpirationCallback(func(key string, value interface{}) { + fmt.Printf("This key(%s) has expired\n", key) + }) + for i := 0; i < 1024; i++ { + cache.Set(fmt.Sprintf("item_%d", i), A{}) + time.Sleep(time.Millisecond * 10) + fmt.Printf("Cache size: %d\n", cache.Count()) + } + + if cache.Count() > 100 { + t.Fatal("Cache should empty entries >1 second old") + } +} + +// test github issue #4 +func TestRemovalAndCountDoesNotPanic(t *testing.T) { + cache := NewCache() + cache.Set("key", "value") + cache.Remove("key") + count := cache.Count() + t.Logf("cache has %d keys\n", count) +} + +// test github issue #3 +func TestRemovalWithTtlDoesNotPanic(t *testing.T) { + cache := NewCache() + cache.SetExpirationCallback(func(key string, value interface{}) { + t.Logf("This key(%s) has expired\n", key) + }) + + cache.SetWithTTL("keyWithTTL", "value", time.Duration(2*time.Second)) + cache.Set("key", "value") + cache.Remove("key") + + value, exists := cache.Get("keyWithTTL") + if exists { + t.Logf("got %s for keyWithTTL\n", value) + } + count := cache.Count() + t.Logf("cache has %d keys\n", count) + + <-time.After(3 * time.Second) + + value, exists = cache.Get("keyWithTTL") + if exists { + t.Logf("got %s for keyWithTTL\n", value) + } else { + t.Logf("keyWithTTL has gone") + } + count = cache.Count() + t.Logf("cache has %d keys\n", count) +} + +func TestCacheIndividualExpirationBiggerThanGlobal(t *testing.T) { + cache := NewCache() + cache.SetTTL(time.Duration(50 * time.Millisecond)) + cache.SetWithTTL("key", "value", time.Duration(100*time.Millisecond)) + <-time.After(150 * time.Millisecond) + data, exists := cache.Get("key") + assert.Equal(t, exists, false, "Expected item to not exist") + assert.Nil(t, data, "Expected item to be nil") +} + +func TestCacheGlobalExpirationByGlobal(t *testing.T) { + cache := NewCache() + cache.Set("key", "value") + <-time.After(50 * time.Millisecond) + data, exists := cache.Get("key") + assert.Equal(t, exists, true, "Expected item to exist in cache") + assert.Equal(t, data.(string), "value", "Expected item to have 'value' in value") + + cache.SetTTL(time.Duration(50 * time.Millisecond)) + data, exists = cache.Get("key") + assert.Equal(t, exists, true, "Expected item to exist in cache") + assert.Equal(t, data.(string), "value", "Expected item to have 'value' in value") + + <-time.After(100 * time.Millisecond) + data, exists = cache.Get("key") + assert.Equal(t, exists, false, "Expected item to not exist") + assert.Nil(t, data, "Expected item to be nil") +} + +func TestCacheGlobalExpiration(t *testing.T) { + cache := NewCache() + cache.SetTTL(time.Duration(100 * time.Millisecond)) + cache.Set("key_1", "value") + cache.Set("key_2", "value") + <-time.After(200 * time.Millisecond) + assert.Equal(t, 0, cache.Count(), "Cache should be empty") + assert.Equal(t, 0, cache.priorityQueue.Len(), "PriorityQueue should be empty") +} + +func TestCacheMixedExpirations(t *testing.T) { + cache := NewCache() + cache.SetExpirationCallback(func(key string, value interface{}) { + t.Logf("expired: %s", key) + }) + cache.Set("key_1", "value") + cache.SetTTL(time.Duration(100 * time.Millisecond)) + cache.Set("key_2", "value") + <-time.After(150 * time.Millisecond) + assert.Equal(t, 1, cache.Count(), "Cache should have only 1 item") +} + +func TestCacheIndividualExpiration(t *testing.T) { + cache := NewCache() + cache.SetWithTTL("key", "value", time.Duration(100*time.Millisecond)) + cache.SetWithTTL("key2", "value", time.Duration(100*time.Millisecond)) + cache.SetWithTTL("key3", "value", time.Duration(100*time.Millisecond)) + <-time.After(50 * time.Millisecond) + assert.Equal(t, cache.Count(), 3, "Should have 3 elements in cache") + <-time.After(160 * time.Millisecond) + assert.Equal(t, cache.Count(), 0, "Cache should be empty") + + cache.SetWithTTL("key4", "value", time.Duration(50*time.Millisecond)) + <-time.After(100 * time.Millisecond) + <-time.After(100 * time.Millisecond) + assert.Equal(t, 0, cache.Count(), "Cache should be empty") +} + +func TestCacheGet(t *testing.T) { + cache := NewCache() + data, exists := cache.Get("hello") + assert.Equal(t, exists, false, "Expected empty cache to return no data") + assert.Nil(t, data, "Expected data to be empty") + + cache.Set("hello", "world") + data, exists = cache.Get("hello") + assert.NotNil(t, data, "Expected data to be not nil") + assert.Equal(t, true, exists, "Expected data to exist") + assert.Equal(t, "world", (data.(string)), "Expected data content to be 'world'") +} + +func TestCacheExpirationCallbackFunction(t *testing.T) { + expiredCount := 0 + var lock sync.Mutex + + cache := NewCache() + cache.SetTTL(time.Duration(500 * time.Millisecond)) + cache.SetExpirationCallback(func(key string, value interface{}) { + lock.Lock() + defer lock.Unlock() + expiredCount = expiredCount + 1 + }) + cache.SetWithTTL("key", "value", time.Duration(1000*time.Millisecond)) + cache.Set("key_2", "value") + <-time.After(1100 * time.Millisecond) + + lock.Lock() + defer lock.Unlock() + assert.Equal(t, 2, expiredCount, "Expected 2 items to be expired") +} + +// TestCacheCheckExpirationCallbackFunction should consider that the next entry in the queue +// needs to be considered for eviction even if the callback returns no eviction for the current item +func TestCacheCheckExpirationCallbackFunction(t *testing.T) { + expiredCount := 0 + var lock sync.Mutex + + cache := NewCache() + cache.SkipTtlExtensionOnHit(true) + cache.SetTTL(time.Duration(50 * time.Millisecond)) + cache.SetCheckExpirationCallback(func(key string, value interface{}) bool { + if key == "key2" || key == "key4" { + return true + } + return false + }) + cache.SetExpirationCallback(func(key string, value interface{}) { + lock.Lock() + expiredCount = expiredCount + 1 + lock.Unlock() + }) + cache.Set("key", "value") + cache.Set("key3", "value") + cache.Set("key2", "value") + cache.Set("key4", "value") + + <-time.After(110 * time.Millisecond) + lock.Lock() + assert.Equal(t, 2, expiredCount, "Expected 2 items to be expired") + lock.Unlock() +} + +func TestCacheNewItemCallbackFunction(t *testing.T) { + newItemCount := 0 + cache := NewCache() + cache.SetTTL(time.Duration(50 * time.Millisecond)) + cache.SetNewItemCallback(func(key string, value interface{}) { + newItemCount = newItemCount + 1 + }) + cache.Set("key", "value") + cache.Set("key2", "value") + cache.Set("key", "value") + <-time.After(110 * time.Millisecond) + assert.Equal(t, 2, newItemCount, "Expected only 2 new items") +} + +func TestCacheRemove(t *testing.T) { + cache := NewCache() + cache.SetTTL(time.Duration(50 * time.Millisecond)) + cache.SetWithTTL("key", "value", time.Duration(100*time.Millisecond)) + cache.Set("key_2", "value") + <-time.After(70 * time.Millisecond) + removeKey := cache.Remove("key") + removeKey2 := cache.Remove("key_2") + assert.Equal(t, true, removeKey, "Expected 'key' to be removed from cache") + assert.Equal(t, false, removeKey2, "Expected 'key_2' to already be expired from cache") +} + +func TestCacheSetWithTTLExistItem(t *testing.T) { + cache := NewCache() + cache.SetTTL(time.Duration(100 * time.Millisecond)) + cache.SetWithTTL("key", "value", time.Duration(50*time.Millisecond)) + <-time.After(30 * time.Millisecond) + cache.SetWithTTL("key", "value2", time.Duration(50*time.Millisecond)) + data, exists := cache.Get("key") + assert.Equal(t, true, exists, "Expected 'key' to exist") + assert.Equal(t, "value2", data.(string), "Expected 'data' to have value 'value2'") +} + +func TestCache_Purge(t *testing.T) { + cache := NewCache() + cache.SetTTL(time.Duration(100 * time.Millisecond)) + + for i := 0; i < 5; i++ { + + cache.SetWithTTL("key", "value", time.Duration(50*time.Millisecond)) + <-time.After(30 * time.Millisecond) + cache.SetWithTTL("key", "value2", time.Duration(50*time.Millisecond)) + cache.Get("key") + + cache.Purge() + assert.Equal(t, 0, cache.Count(), "Cache should be empty") + } + +} + +func BenchmarkCacheSetWithoutTTL(b *testing.B) { + cache := NewCache() + for n := 0; n < b.N; n++ { + cache.Set(string(n), "value") + } +} + +func BenchmarkCacheSetWithGlobalTTL(b *testing.B) { + cache := NewCache() + cache.SetTTL(time.Duration(50 * time.Millisecond)) + for n := 0; n < b.N; n++ { + cache.Set(string(n), "value") + } +} + +func BenchmarkCacheSetWithTTL(b *testing.B) { + cache := NewCache() + for n := 0; n < b.N; n++ { + cache.SetWithTTL(string(n), "value", time.Duration(50*time.Millisecond)) + } +} diff --git a/item.go b/item.go new file mode 100644 index 0000000..2f78f49 --- /dev/null +++ b/item.go @@ -0,0 +1,46 @@ +package ttlcache + +import ( + "time" +) + +const ( + // ItemNotExpire Will avoid the item being expired by TTL, but can still be exired by callback etc. + ItemNotExpire time.Duration = -1 + // ItemExpireWithGlobalTTL will use the global TTL when set. + ItemExpireWithGlobalTTL time.Duration = 0 +) + +func newItem(key string, data interface{}, ttl time.Duration) *item { + item := &item{ + data: data, + ttl: ttl, + key: key, + } + // since nobody is aware yet of this item, it's safe to touch without lock here + item.touch() + return item +} + +type item struct { + key string + data interface{} + ttl time.Duration + expireAt time.Time + queueIndex int +} + +// Reset the item expiration time +func (item *item) touch() { + if item.ttl > 0 { + item.expireAt = time.Now().Add(item.ttl) + } +} + +// Verify if the item is expired +func (item *item) expired() bool { + if item.ttl <= 0 { + return false + } + return item.expireAt.Before(time.Now()) +} diff --git a/item_test.go b/item_test.go new file mode 100644 index 0000000..dcaacd3 --- /dev/null +++ b/item_test.go @@ -0,0 +1,34 @@ +package ttlcache + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestItemExpired(t *testing.T) { + item := newItem("key", "value", (time.Duration(100) * time.Millisecond)) + assert.Equal(t, item.expired(), false, "Expected item to not be expired") + <-time.After(200 * time.Millisecond) + assert.Equal(t, item.expired(), true, "Expected item to be expired once time has passed") +} + +func TestItemTouch(t *testing.T) { + item := newItem("key", "value", (time.Duration(100) * time.Millisecond)) + oldExpireAt := item.expireAt + <-time.After(50 * time.Millisecond) + item.touch() + assert.NotEqual(t, oldExpireAt, item.expireAt, "Expected dates to be different") + <-time.After(150 * time.Millisecond) + assert.Equal(t, item.expired(), true, "Expected item to be expired") + item.touch() + <-time.After(50 * time.Millisecond) + assert.Equal(t, item.expired(), false, "Expected item to not be expired") +} + +func TestItemWithoutExpiration(t *testing.T) { + item := newItem("key", "value", ItemNotExpire) + <-time.After(50 * time.Millisecond) + assert.Equal(t, item.expired(), false, "Expected item to not be expired") +} diff --git a/priority_queue.go b/priority_queue.go new file mode 100644 index 0000000..11b9c31 --- /dev/null +++ b/priority_queue.go @@ -0,0 +1,71 @@ +package ttlcache + +import ( + "container/heap" +) + +func newPriorityQueue() *priorityQueue { + queue := &priorityQueue{} + heap.Init(queue) + return queue +} + +type priorityQueue struct { + items []*item +} + +func (pq *priorityQueue) update(item *item) { + heap.Fix(pq, item.queueIndex) +} + +func (pq *priorityQueue) push(item *item) { + heap.Push(pq, item) +} + +func (pq *priorityQueue) pop() *item { + if pq.Len() == 0 { + return nil + } + return heap.Pop(pq).(*item) +} + +func (pq *priorityQueue) remove(item *item) { + heap.Remove(pq, item.queueIndex) +} + +func (pq priorityQueue) Len() int { + length := len(pq.items) + return length +} + +// Less will consider items with time.Time default value (epoch start) as more than set items. +func (pq priorityQueue) Less(i, j int) bool { + if pq.items[i].expireAt.IsZero() { + return false + } + if pq.items[j].expireAt.IsZero() { + return true + } + return pq.items[i].expireAt.Before(pq.items[j].expireAt) +} + +func (pq priorityQueue) Swap(i, j int) { + pq.items[i], pq.items[j] = pq.items[j], pq.items[i] + pq.items[i].queueIndex = i + pq.items[j].queueIndex = j +} + +func (pq *priorityQueue) Push(x interface{}) { + item := x.(*item) + item.queueIndex = len(pq.items) + pq.items = append(pq.items, item) +} + +func (pq *priorityQueue) Pop() interface{} { + old := pq.items + n := len(old) + item := old[n-1] + item.queueIndex = -1 + pq.items = old[0 : n-1] + return item +} diff --git a/priority_queue_test.go b/priority_queue_test.go new file mode 100644 index 0000000..0d66207 --- /dev/null +++ b/priority_queue_test.go @@ -0,0 +1,89 @@ +package ttlcache + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestPriorityQueuePush(t *testing.T) { + queue := newPriorityQueue() + for i := 0; i < 10; i++ { + queue.push(newItem(fmt.Sprintf("key_%d", i), "data", -1)) + } + assert.Equal(t, queue.Len(), 10, "Expected queue to have 10 elements") +} + +func TestPriorityQueuePop(t *testing.T) { + queue := newPriorityQueue() + for i := 0; i < 10; i++ { + queue.push(newItem(fmt.Sprintf("key_%d", i), "data", -1)) + } + for i := 0; i < 5; i++ { + item := queue.pop() + assert.Equal(t, fmt.Sprintf("%T", item), "*ttlcache.item", "Expected 'item' to be a '*ttlcache.item'") + } + assert.Equal(t, queue.Len(), 5, "Expected queue to have 5 elements") + for i := 0; i < 5; i++ { + item := queue.pop() + assert.Equal(t, fmt.Sprintf("%T", item), "*ttlcache.item", "Expected 'item' to be a '*ttlcache.item'") + } + assert.Equal(t, queue.Len(), 0, "Expected queue to have 0 elements") + + item := queue.pop() + assert.Nil(t, item, "*ttlcache.item", "Expected 'item' to be nil") +} + +func TestPriorityQueueCheckOrder(t *testing.T) { + queue := newPriorityQueue() + for i := 10; i > 0; i-- { + queue.push(newItem(fmt.Sprintf("key_%d", i), "data", time.Duration(i)*time.Second)) + } + for i := 1; i <= 10; i++ { + item := queue.pop() + assert.Equal(t, item.key, fmt.Sprintf("key_%d", i), "error") + } +} + +func TestPriorityQueueRemove(t *testing.T) { + queue := newPriorityQueue() + items := make(map[string]*item) + var itemRemove *item + for i := 0; i < 5; i++ { + key := fmt.Sprintf("key_%d", i) + items[key] = newItem(key, "data", time.Duration(i)*time.Second) + queue.push(items[key]) + + if i == 2 { + itemRemove = items[key] + } + } + assert.Equal(t, queue.Len(), 5, "Expected queue to have 5 elements") + queue.remove(itemRemove) + assert.Equal(t, queue.Len(), 4, "Expected queue to have 4 elements") + + for { + item := queue.pop() + if item == nil { + break + } + assert.NotEqual(t, itemRemove.key, item.key, "This element was not supose to be in the queue") + } + + assert.Equal(t, queue.Len(), 0, "The queue is supose to be with 0 items") +} + +func TestPriorityQueueUpdate(t *testing.T) { + queue := newPriorityQueue() + item := newItem("key", "data", 1*time.Second) + queue.push(item) + assert.Equal(t, queue.Len(), 1, "The queue is supose to be with 1 item") + + item.key = "newKey" + queue.update(item) + newItem := queue.pop() + assert.Equal(t, newItem.key, "newKey", "The item key didn't change") + assert.Equal(t, queue.Len(), 0, "The queue is supose to be with 0 items") +}