Skip to content

Commit

Permalink
Add RedisCache (#5)
Browse files Browse the repository at this point in the history
* Add RedisCache

Issue #3

* Fix CI errors

* Fix CI

* Fix comments

* Fix comments, increase test coverage

* Add additional condition in RedisCache constructor
  • Loading branch information
tyzhnenko authored and umputun committed Oct 18, 2019
1 parent 51598f3 commit 711af43
Show file tree
Hide file tree
Showing 7 changed files with 410 additions and 24 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,26 @@

The library adds a thin layer on top of [lru cache](https://github.com/hashicorp/golang-lru) and [patrickmn/go-cache](https://github.com/patrickmn/go-cache).

| Cache name | Constructor | Defaults | Description |
| -------------- | --------------------- | ----------------- | --------------------- |
| LruCache | lcw.NewLruCache | keys=1000 | LRU cache with limits |
| ExpirableCache | lcw.NewExpirableCache | keys=1000, ttl=5m | TTL cache with limits |
| Nop | lcw.NewNopCache | | Do-nothing cache |
| Cache name | Constructor | Defaults | Description |
| -------------- | --------------------- | ----------------- | ----------------------- |
| LruCache | lcw.NewLruCache | keys=1000 | LRU cache with limits |
| ExpirableCache | lcw.NewExpirableCache | keys=1000, ttl=5m | TTL cache with limits |
| RedisCache | lcw.NewRedisCache | ttl=5m | Redis cache with limits |
| Nop | lcw.NewNopCache | | Do-nothing cache |


Main features:

- LoadingCache (guava style)
- Limit maximum cache size (in bytes)
- Limit maximum key size
- Limit maximum size of a value
- Limit maximum size of a value
- Limit number of keys
- TTL support (`ExpirableCache` only)
- Callback on eviction event
- TTL support (`ExpirableCache` and `RedisCache`)
- Callback on eviction event (not supported in `RedisCache`)
- Functional style invalidation
- Functional options
- Sane defaults

## Install and update

`go get -u github.com/go-pkgz/lcw`
Expand Down
86 changes: 73 additions & 13 deletions cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

redis "github.com/go-redis/redis/v7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -112,19 +113,28 @@ func TestCache_MaxValueSize(t *testing.T) {
res, err = c.Get("key-Z", func() (Value, error) {
return sizedString("result-Zzzz"), nil
})
if s, ok := res.(string); ok {
res = sizedString(s)
}
assert.NoError(t, err)
assert.Equal(t, sizedString("result-Z"), res.(sizedString), "got cached value")

// put too big value to cache and make sure it is not cached
res, err = c.Get("key-Big", func() (Value, error) {
return sizedString("1234567890"), nil
})
if s, ok := res.(string); ok {
res = sizedString(s)
}
assert.NoError(t, err)
assert.Equal(t, sizedString("1234567890"), res.(sizedString))

res, err = c.Get("key-Big", func() (Value, error) {
return sizedString("result-big"), nil
})
if s, ok := res.(string); ok {
res = sizedString(s)
}
assert.NoError(t, err)
assert.Equal(t, sizedString("result-big"), res.(sizedString), "got not cached value")

Expand Down Expand Up @@ -154,28 +164,39 @@ func TestCache_MaxCacheSize(t *testing.T) {
return sizedString("result-Z"), nil
})
assert.NoError(t, err)
if s, ok := res.(string); ok {
res = sizedString(s)
}
assert.Equal(t, sizedString("result-Z"), res.(sizedString))

res, err = c.Get("key-Z", func() (Value, error) {
return sizedString("result-Zzzz"), nil
})
if s, ok := res.(string); ok {
res = sizedString(s)
}
assert.NoError(t, err)
assert.Equal(t, sizedString("result-Z"), res.(sizedString), "got cached value")
assert.Equal(t, int64(8), c.size())

if _, ok := c.(*RedisCache); !ok {
assert.Equal(t, int64(8), c.size())
}
_, err = c.Get("key-Z2", func() (Value, error) {
return sizedString("result-Y"), nil
})
assert.Nil(t, err)
assert.Equal(t, int64(16), c.size())
if _, ok := c.(*RedisCache); !ok {
assert.Equal(t, int64(16), c.size())
}

// this will cause removal
_, err = c.Get("key-Z3", func() (Value, error) {
return sizedString("result-Z"), nil
})
assert.Nil(t, err)
assert.Equal(t, int64(16), c.size())
assert.Equal(t, 2, c.keys())
if _, ok := c.(*RedisCache); !ok {
assert.Equal(t, int64(16), c.size())
// Due RedisCache does not support MaxCacheSize this assert should be skipped
assert.Equal(t, 2, c.keys())
}
})
}
}
Expand Down Expand Up @@ -371,11 +392,14 @@ func TestCache_Delete(t *testing.T) {
require.Nil(t, err)
}
assert.Equal(t, 1000, c.Stat().Keys)
assert.Equal(t, int64(9890), c.Stat().Size)

if _, ok := c.(*RedisCache); !ok {
assert.Equal(t, int64(9890), c.Stat().Size)
}
c.Delete("key-2")
assert.Equal(t, 999, c.Stat().Keys)
assert.Equal(t, int64(9890-8), c.Stat().Size)
if _, ok := c.(*RedisCache); !ok {
assert.Equal(t, int64(9890-8), c.Stat().Size)
}
})
}
}
Expand All @@ -392,8 +416,12 @@ func TestCache_DeleteWithEvent(t *testing.T) {

caches := cachesTestList(t, OnEvicted(onEvict))
for _, c := range caches {

evKey, evVal, evCount = "", "", 0
t.Run(strings.Replace(fmt.Sprintf("%T", c), "*lcw.", "", 1), func(t *testing.T) {
if _, ok := c.(*RedisCache); ok {
t.Skip("RedisCache doesn't support delete events")
}
// fill cache
for i := 0; i < 1000; i++ {
_, err := c.Get(fmt.Sprintf("key-%d", i), func() (Value, error) {
Expand Down Expand Up @@ -425,25 +453,45 @@ func TestCache_Stats(t *testing.T) {
require.Nil(t, err)
}
stats := c.Stat()
assert.Equal(t, CacheStat{Hits: 0, Misses: 100, Keys: 100, Size: 890}, stats)
switch c.(type) {
case *RedisCache:
assert.Equal(t, CacheStat{Hits: 0, Misses: 100, Keys: 100, Size: 0}, stats)
default:
assert.Equal(t, CacheStat{Hits: 0, Misses: 100, Keys: 100, Size: 890}, stats)
}

_, err := c.Get("key-1", func() (Value, error) {
return "xyz", nil
})
require.NoError(t, err)
assert.Equal(t, CacheStat{Hits: 1, Misses: 100, Keys: 100, Size: 890}, c.Stat())
switch c.(type) {
case *RedisCache:
assert.Equal(t, CacheStat{Hits: 1, Misses: 100, Keys: 100, Size: 0}, c.Stat())
default:
assert.Equal(t, CacheStat{Hits: 1, Misses: 100, Keys: 100, Size: 890}, c.Stat())
}

_, err = c.Get("key-1123", func() (Value, error) {
return sizedString("xyz"), nil
})
require.NoError(t, err)
assert.Equal(t, CacheStat{Hits: 1, Misses: 101, Keys: 101, Size: 893}, c.Stat())
switch c.(type) {
case *RedisCache:
assert.Equal(t, CacheStat{Hits: 1, Misses: 101, Keys: 101, Size: 0}, c.Stat())
default:
assert.Equal(t, CacheStat{Hits: 1, Misses: 101, Keys: 101, Size: 893}, c.Stat())
}

_, err = c.Get("key-9999", func() (Value, error) {
return nil, errors.New("err")
})
require.NotNil(t, err)
assert.Equal(t, CacheStat{Hits: 1, Misses: 101, Keys: 101, Size: 893, Errors: 1}, c.Stat())
switch c.(type) {
case *RedisCache:
assert.Equal(t, CacheStat{Hits: 1, Misses: 101, Keys: 101, Size: 0, Errors: 1}, c.Stat())
default:
assert.Equal(t, CacheStat{Hits: 1, Misses: 101, Keys: 101, Size: 893, Errors: 1}, c.Stat())
}
})

}
Expand Down Expand Up @@ -515,9 +563,21 @@ func cachesTestList(t *testing.T, opts ...Option) []countedCache {
lc, err := NewLruCache(opts...)
require.NoError(t, err, "can't make lru cache")
caches = append(caches, lc)

server := newTestRedisServer()
client := redis.NewClient(&redis.Options{
Addr: server.Addr()})
rc, err := NewRedisCache(client, opts...)
require.NoError(t, err, "can't make redis cache")
caches = append(caches, rc)

return caches
}

type sizedString string

func (s sizedString) Size() int { return len(s) }

func (s sizedString) MarshalBinary() (data []byte, err error) {
return []byte(s), nil
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
module github.com/go-pkgz/lcw

require (
github.com/alicebob/miniredis v2.5.0+incompatible
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-redis/redis/v7 v7.0.0-beta.4
github.com/hashicorp/golang-lru v0.5.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.8.1
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
github.com/alicebob/miniredis v2.5.0+incompatible h1:yBHoLpsyjupjz3NL3MhKMVkR41j82Yjf3KFv7ApYzUI=
github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-redis/redis/v7 v7.0.0-beta.4 h1:p6z7Pde69EGRWvlC++y8aFcaWegyrKHzOBGo0zUACTQ=
github.com/go-redis/redis/v7 v7.0.0-beta.4/go.mod h1:xhhSbUMTsleRPur+Vgx9sUHtyN33bdjxY+9/0n9Ig8s=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
Expand Down
2 changes: 1 addition & 1 deletion lru_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"github.com/pkg/errors"
)

// LruCache wraps lru.LruCache with laoding cache Get and size limits
// LruCache wraps lru.LruCache with loading cache Get and size limits
type LruCache struct {
options
CacheStat
Expand Down
138 changes: 138 additions & 0 deletions redis_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package lcw

import (
"sync/atomic"
"time"

redis "github.com/go-redis/redis/v7"
"github.com/pkg/errors"
)

// RedisValueSizeLimit is maximum allowed value size in Redis
const RedisValueSizeLimit = 512 * 1024 * 1024

// RedisCache implements LoadingCache for Redis.
type RedisCache struct {
options
CacheStat
backend *redis.Client
}

// NewRedisCache makes Redis LoadingCache implementation.
func NewRedisCache(backend *redis.Client, opts ...Option) (*RedisCache, error) {

res := RedisCache{
options: options{
ttl: 5 * time.Minute,
},
}
for _, opt := range opts {
if err := opt(&res.options); err != nil {
return nil, errors.Wrap(err, "failed to set cache option")
}
}

if res.maxValueSize <= 0 || res.maxValueSize > RedisValueSizeLimit {
res.maxValueSize = RedisValueSizeLimit
}

res.backend = backend

return &res, nil
}

// Get gets value by key or load with fn if not found in cache
func (c *RedisCache) Get(key string, fn func() (Value, error)) (data Value, err error) {

v, getErr := c.backend.Get(key).Result()
switch getErr {
// RedisClient returns nil when find a key in DB
case nil:
atomic.AddInt64(&c.Hits, 1)
return v, nil
// RedisClient returns redis.Nil when doesn't find a key in DB
case redis.Nil:
if data, err = fn(); err != nil {
atomic.AddInt64(&c.Errors, 1)
return data, err
}
// RedisClient returns !nil when somthing goes wrong while get data
default:
atomic.AddInt64(&c.Errors, 1)
return v, getErr
}
atomic.AddInt64(&c.Misses, 1)

if c.allowed(key, data) {
_, setErr := c.backend.Set(key, data, c.ttl).Result()
if setErr != nil {
atomic.AddInt64(&c.Errors, 1)
return data, setErr
}

}
return data, nil
}

// Invalidate removes keys with passed predicate fn, i.e. fn(key) should be true to get evicted
func (c *RedisCache) Invalidate(fn func(key string) bool) {
for _, key := range c.backend.Keys("*").Val() { // Keys() returns copy of cache's key, safe to remove directly
if fn(key) {
c.backend.Del(key)
}
}
}

// Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key.
func (c *RedisCache) Peek(key string) (Value, bool) {
ret, err := c.backend.Get(key).Result()
if err != nil {
return nil, false
}
return ret, true
}

// Purge clears the cache completely.
func (c *RedisCache) Purge() {
c.backend.FlushDB()

}

// Delete cache item by key
func (c *RedisCache) Delete(key string) {
c.backend.Del(key)
}

// Stat returns cache statistics
func (c *RedisCache) Stat() CacheStat {
return CacheStat{
Hits: c.Hits,
Misses: c.Misses,
Size: c.size(),
Keys: c.keys(),
Errors: c.Errors,
}
}

func (c *RedisCache) size() int64 {
return 0
}

func (c *RedisCache) keys() int {
return int(c.backend.DBSize().Val())
}

func (c *RedisCache) allowed(key string, data Value) bool {
if c.maxKeys > 0 && c.backend.DBSize().Val() >= int64(c.maxKeys) {
return false
}
if c.maxKeySize > 0 && len(key) > c.maxKeySize {
return false
}
if s, ok := data.(Sizer); ok {
if c.maxValueSize > 0 && (s.Size() >= c.maxValueSize) {
return false
}
}
return true
}
Loading

0 comments on commit 711af43

Please sign in to comment.