diff --git a/.travis.yml b/.travis.yml index 63798b1..c447fde 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,8 @@ language: go go: + - "1.17.x" - "1.16.x" - - "1.15.x" git: depth: 1 diff --git a/Readme.md b/Readme.md index 2ab17e3..6bc07b8 100644 --- a/Readme.md +++ b/Readme.md @@ -15,13 +15,15 @@ Note (issue #25): by default, due to historic reasons, the TTL will be reset on each cache hit and you need to explicitly configure the cache to use a TTL that will not get extended. -[![Build Status](https://travis-ci.org/ReneKroon/ttlcache.svg?branch=master)](https://travis-ci.org/ReneKroon/ttlcache) +[![Build Status](https://www.travis-ci.com/ReneKroon/ttlcache.svg?branch=master)](https://travis-ci.com/ReneKroon/ttlcache) [![Go Report Card](https://goreportcard.com/badge/github.com/ReneKroon/ttlcache)](https://goreportcard.com/report/github.com/ReneKroon/ttlcache) [![Coverage Status](https://coveralls.io/repos/github/ReneKroon/ttlcache/badge.svg?branch=master)](https://coveralls.io/github/ReneKroon/ttlcache?branch=master) [![GitHub issues](https://img.shields.io/github/issues/ReneKroon/ttlcache.svg)](https://github.com/ReneKroon/ttlcache/issues) [![license](https://img.shields.io/github/license/ReneKroon/ttlcache.svg?maxAge=2592000)](https://github.com/ReneKroon/ttlcache/LICENSE) ## Usage + +`go get github.com/ReneKroon/ttlcache/v2` You can copy it as a full standalone demo program. The first snippet is basic usage, where the second exploits more options in the cache. diff --git a/cache.go b/cache.go index 53227a4..f59c491 100644 --- a/cache.go +++ b/cache.go @@ -1,9 +1,10 @@ package ttlcache import ( - "golang.org/x/sync/singleflight" "sync" "time" + + "golang.org/x/sync/singleflight" ) // CheckExpireCallback is used as a callback for an external check on item expiration @@ -22,6 +23,7 @@ // SimpleCache interface enables a quick-start. Interface for basic usage. type SimpleCache interface { Get(key string) (interface{}, error) + GetWithTTL(key string) (interface{}, time.Duration, error) Set(key string, data interface{}) error SetTTL(ttl time.Duration) error SetWithTTL(key string, data interface{}, ttl time.Duration) error @@ -281,21 +283,39 @@ return cache.GetByLoader(key, nil) } +// GetWithTTL has exactly the same behaviour as Get but also returns +// the remaining TTL for an specific item at the moment it its retrieved +func (cache *Cache) GetWithTTL(key string) (interface{}, time.Duration, error) { + return cache.GetByLoaderWithTtl(key, nil) +} + // GetByLoader can take a per key loader function (ie. to propagate context) func (cache *Cache) GetByLoader(key string, customLoaderFunction LoaderFunction) (interface{}, error) { - cache.mutex.Lock() - if cache.isShutDown { - cache.mutex.Unlock() - return nil, ErrClosed + dataToReturn, _, err := cache.GetByLoaderWithTtl(key, customLoaderFunction) + + return dataToReturn, err +} + +// GetByLoaderWithTtl can take a per key loader function (ie. to propagate context) +func (cache *Cache) GetByLoaderWithTtl(key string, customLoaderFunction LoaderFunction) (interface{}, time.Duration, error) { + cache.mutex.Lock() + if cache.isShutDown { + cache.mutex.Unlock() + return nil, 0, ErrClosed } cache.metrics.Hits++ item, exists, triggerExpirationNotification := cache.getItem(key) var dataToReturn interface{} + ttlToReturn := time.Duration(0) if exists { cache.metrics.Retrievals++ dataToReturn = item.data + ttlToReturn = time.Until(item.expireAt) + if ttlToReturn < 0 { + ttlToReturn = 0 + } } var err error @@ -314,24 +334,34 @@ } if loaderFunction != nil && !exists { - cache.mutex.Unlock() - dataToReturn, err, _ = cache.loaderLock.Do(key, func() (interface{}, error) { + type loaderResult struct { + data interface{} + ttl time.Duration + } + ch := cache.loaderLock.DoChan(key, func() (interface{}, error) { // cache is not blocked during io - invokeData, err := cache.invokeLoader(key, loaderFunction) - return invokeData, err + invokeData, ttl, err := cache.invokeLoader(key, loaderFunction) + lr := &loaderResult{ + data: invokeData, + ttl: ttl, + } + return lr, err }) + cache.mutex.Unlock() + res := <-ch + dataToReturn = res.Val.(*loaderResult).data + ttlToReturn = res.Val.(*loaderResult).ttl + err = res.Err } if triggerExpirationNotification { cache.expirationNotification <- true } - return dataToReturn, err -} - -func (cache *Cache) invokeLoader(key string, loaderFunction LoaderFunction) (dataToReturn interface{}, err error) { - var ttl time.Duration - + return dataToReturn, ttlToReturn, err +} + +func (cache *Cache) invokeLoader(key string, loaderFunction LoaderFunction) (dataToReturn interface{}, ttl time.Duration, err error) { dataToReturn, ttl, err = loaderFunction(key) if err == nil { err = cache.SetWithTTL(key, dataToReturn, ttl) @@ -339,7 +369,7 @@ dataToReturn = nil } } - return dataToReturn, err + return dataToReturn, ttl, err } // Remove removes an item from the cache if it exists, triggers expiration callback when set. Can return ErrNotFound if the entry was not present. diff --git a/cache_test.go b/cache_test.go index 58a04af..0daff1a 100644 --- a/cache_test.go +++ b/cache_test.go @@ -33,6 +33,46 @@ } +// Issue 45 : This test was used to test different code paths for best performance. +func TestCache_GetByLoaderRace(t *testing.T) { + t.Skip() + t.Parallel() + cache := NewCache() + cache.SetTTL(time.Microsecond) + defer cache.Close() + + loaderInvocations := uint64(0) + inFlight := uint64(0) + + globalLoader := func(key string) (data interface{}, ttl time.Duration, err error) { + atomic.AddUint64(&inFlight, 1) + atomic.AddUint64(&loaderInvocations, 1) + time.Sleep(time.Microsecond) + assert.Equal(t, uint64(1), inFlight) + defer atomic.AddUint64(&inFlight, ^uint64(0)) + return "global", 0, nil + + } + cache.SetLoaderFunction(globalLoader) + + for i := 0; i < 1000; i++ { + wg := sync.WaitGroup{} + for i := 0; i < 1000; i++ { + wg.Add(1) + go func() { + key, _ := cache.Get("test") + assert.Equal(t, "global", key) + wg.Done() + + }() + } + wg.Wait() + t.Logf("Invocations: %d\n", loaderInvocations) + loaderInvocations = 0 + } + +} + // Issue / PR #39: add customer loader function for each Get() # // some middleware prefers to define specific context's etc per Get. // This is faciliated by supplying a loder function with Get's. @@ -68,6 +108,43 @@ defaultKey, _ := cache.GetByLoader("test", nil) assert.Equal(t, "global", defaultKey) + cache.Remove("test") +} + +func TestCache_GetByLoaderWithTtl(t *testing.T) { + t.Parallel() + cache := NewCache() + defer cache.Close() + + globalTtl := time.Duration(time.Minute) + globalLoader := func(key string) (data interface{}, ttl time.Duration, err error) { + return "global", globalTtl, nil + } + cache.SetLoaderFunction(globalLoader) + + localTtl := time.Duration(time.Hour) + localLoader := func(key string) (data interface{}, ttl time.Duration, err error) { + return "local", localTtl, nil + } + + key, ttl, _ := cache.GetWithTTL("test") + assert.Equal(t, "global", key) + assert.Equal(t, ttl, globalTtl) + cache.Remove("test") + + localKey, ttl2, _ := cache.GetByLoaderWithTtl("test", localLoader) + assert.Equal(t, "local", localKey) + assert.Equal(t, ttl2, localTtl) + cache.Remove("test") + + globalKey, ttl3, _ := cache.GetByLoaderWithTtl("test", globalLoader) + assert.Equal(t, "global", globalKey) + assert.Equal(t, ttl3, globalTtl) + cache.Remove("test") + + defaultKey, ttl4, _ := cache.GetByLoaderWithTtl("test", nil) + assert.Equal(t, "global", defaultKey) + assert.Equal(t, ttl4, globalTtl) cache.Remove("test") } @@ -752,6 +829,64 @@ assert.Equal(t, []string{"hello"}, keys, "Expected keys contains 'hello'") } +func TestCacheGetWithTTL(t *testing.T) { + t.Parallel() + + cache := NewCache() + defer cache.Close() + + data, ttl, exists := cache.GetWithTTL("hello") + assert.Equal(t, exists, ErrNotFound, "Expected empty cache to return no data") + assert.Nil(t, data, "Expected data to be empty") + assert.Equal(t, int(ttl), 0, "Expected item TTL to be 0") + + cache.Set("hello", "world") + data, ttl, exists = cache.GetWithTTL("hello") + assert.NotNil(t, data, "Expected data to be not nil") + assert.Equal(t, nil, exists, "Expected data to exist") + assert.Equal(t, "world", (data.(string)), "Expected data content to be 'world'") + assert.Equal(t, int(ttl), 0, "Expected item TTL to be 0") + + orgttl := time.Duration(500 * time.Millisecond) + cache.SetWithTTL("hello", "world", orgttl) + time.Sleep(10 * time.Millisecond) + data, ttl, exists = cache.GetWithTTL("hello") + assert.NotNil(t, data, "Expected data to be not nil") + assert.Equal(t, nil, exists, "Expected data to exist") + assert.Equal(t, "world", (data.(string)), "Expected data content to be 'world'") + assert.Less(t, ttl, orgttl, "Expected item TTL to be less than the original TTL") + assert.NotEqual(t, int(ttl), 0, "Expected item TTL to be not 0") +} + +func TestCache_TestGetWithTTLAndLoaderFunction(t *testing.T) { + t.Parallel() + cache := NewCache() + + cache.SetLoaderFunction(func(key string) (data interface{}, ttl time.Duration, err error) { + return nil, 0, ErrNotFound + }) + + _, ttl, err := cache.GetWithTTL("1") + assert.Equal(t, ErrNotFound, err, "Expected error to be ErrNotFound") + assert.Equal(t, int(ttl), 0, "Expected item TTL to be 0") + + orgttl := time.Duration(1 * time.Second) + cache.SetLoaderFunction(func(key string) (data interface{}, ttl time.Duration, err error) { + return "1", orgttl, nil + }) + + value, ttl, found := cache.GetWithTTL("1") + assert.Equal(t, nil, found) + assert.Equal(t, "1", value) + assert.Equal(t, ttl, orgttl, "Expected item TTL to be the same as the original TTL") + cache.Close() + + value, ttl, found = cache.GetWithTTL("1") + assert.Equal(t, ErrClosed, found) + assert.Equal(t, nil, value) + assert.Equal(t, int(ttl), 0, "Expected returned ttl for an ErrClosed err to be 0") +} + func TestCacheExpirationCallbackFunction(t *testing.T) { t.Parallel()