diff --git a/README.md b/README.md index ddea286..cd4216d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Here is what it brings in detail: * ✅ A metric cache to let you store metrics about your caches usage (hits, miss, set success, set error, ...) * ✅ A marshaler to automatically marshal/unmarshal your cache values as a struct * ✅ Define default values in stores and override them when setting data +* ✅ Cache invalidation by expiration time and/or using tags ## Built-in stores @@ -228,6 +229,52 @@ marshal.Delete("my-key") The only thing you have to do is to specify the struct in which you want your value to be unmarshalled as a second argument when calling the `.Get()` method. +### Cache invalidation using tags + +You can attach some tags to items you create so you can easily invalidate some of them later. + +Tags are stored using the same storage you choose for your cache. + +Here is an example on how to use it: + +```go +// Initialize Redis client and store +redisClient := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}) +redisStore := store.NewRedis(redisClient, nil) + +// Initialize chained cache +cacheManager := cache.NewMetric( + promMetrics, + cache.New(redisStore), +) + +// Initializes marshaler +marshal := marshaler.New(cacheManager) + +key := BookQuery{Slug: "my-test-amazing-book"} +value := Book{ID: 1, Name: "My test amazing book", Slug: "my-test-amazing-book"} + +// Set an item in the cache and attach it a "book" tag +err = marshal.Set(key, value, store.Options{Tags: []string{"book"}}) +if err != nil { + panic(err) +} + +// Remove all items that have the "book" tag +err := marshal.Invalidate(store.InvalidateOptions{Tags: []string{"book"}}) +if err != nil { + panic(err) +} + +returnedValue, err := marshal.Get(key, new(Book)) +if err != nil { + // Should be triggered because item has been deleted so it cannot be found. + panic(err) +} +``` + +Mix this with expiration times on your caches to have a fine tuned control on how your data are cached. + ### All together! Finally, you can mix all of these available caches or bring them together to build the cache you want to. diff --git a/cache/cache.go b/cache/cache.go index 156361d..1924073 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -41,6 +41,11 @@ func (c *Cache) Delete(key interface{}) error { return c.codec.Delete(cacheKey) } +// Invalidate invalidates cache item from given options +func (c *Cache) Invalidate(options store.InvalidateOptions) error { + return c.codec.Invalidate(options) +} + // GetCodec returns the current codec func (c *Cache) GetCodec() codec.CodecInterface { return c.codec diff --git a/cache/cache_test.go b/cache/cache_test.go index 6291fe3..8113c19 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -150,6 +150,44 @@ func TestCacheDelete(t *testing.T) { assert.Nil(t, err) } +func TestCacheInvalidate(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + store := &mocksStore.StoreInterface{} + store.On("Invalidate", options).Return(nil) + + cache := New(store) + + // When + err := cache.Invalidate(options) + + // Then + assert.Nil(t, err) +} + +func TestCacheInvalidateWhenError(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + expectedErr := errors.New("Unexpected error during invalidation") + + store := &mocksStore.StoreInterface{} + store.On("Invalidate", options).Return(expectedErr) + + cache := New(store) + + // When + err := cache.Invalidate(options) + + // Then + assert.Equal(t, expectedErr, err) +} + func TestCacheDeleteWhenError(t *testing.T) { // Given expectedErr := errors.New("Unable to delete key") diff --git a/cache/chain.go b/cache/chain.go index 4e57955..152743d 100644 --- a/cache/chain.go +++ b/cache/chain.go @@ -65,6 +65,15 @@ func (c *ChainCache) Delete(key interface{}) error { return nil } +// Invalidate invalidates cache item from given options +func (c *ChainCache) Invalidate(options store.InvalidateOptions) error { + for _, cache := range c.caches { + cache.Invalidate(options) + } + + return nil +} + // setUntil sets a value in available caches, eventually until a given cache layer func (c *ChainCache) setUntil(key, object interface{}, until *string) error { for _, cache := range c.caches { diff --git a/cache/chain_test.go b/cache/chain_test.go index 1f8235c..5c95178 100644 --- a/cache/chain_test.go +++ b/cache/chain_test.go @@ -156,6 +156,52 @@ func TestChainDeleteWhenError(t *testing.T) { assert.Nil(t, err) } +func TestChainInvalidate(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + // Cache 1 + cache1 := &mocksCache.SetterCacheInterface{} + cache1.On("Invalidate", options).Return(nil) + + // Cache 2 + cache2 := &mocksCache.SetterCacheInterface{} + cache2.On("Invalidate", options).Return(nil) + + cache := NewChain(cache1, cache2) + + // When + err := cache.Invalidate(options) + + // Then + assert.Nil(t, err) +} + +func TestChainInvalidateWhenError(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + // Cache 1 + cache1 := &mocksCache.SetterCacheInterface{} + cache1.On("Invalidate", options).Return(errors.New("An unexpected error has occured while invalidation data")) + + // Cache 2 + cache2 := &mocksCache.SetterCacheInterface{} + cache2.On("Invalidate", options).Return(nil) + + cache := NewChain(cache1, cache2) + + // When + err := cache.Invalidate(options) + + // Then + assert.Nil(t, err) +} + func TestChainGetType(t *testing.T) { // Given cache1 := &mocksCache.SetterCacheInterface{} diff --git a/cache/interface.go b/cache/interface.go index 07b5536..b438a08 100644 --- a/cache/interface.go +++ b/cache/interface.go @@ -10,6 +10,7 @@ type CacheInterface interface { Get(key interface{}) (interface{}, error) Set(key, object interface{}, options *store.Options) error Delete(key interface{}) error + Invalidate(options store.InvalidateOptions) error GetType() string } diff --git a/cache/loadable.go b/cache/loadable.go index 447945f..f1e0656 100644 --- a/cache/loadable.go +++ b/cache/loadable.go @@ -58,6 +58,11 @@ func (c *LoadableCache) Delete(key interface{}) error { return c.cache.Delete(key) } +// Invalidate invalidates cache item from given options +func (c *LoadableCache) Invalidate(options store.InvalidateOptions) error { + return c.cache.Invalidate(options) +} + // GetType returns the cache type func (c *LoadableCache) GetType() string { return LoadableType diff --git a/cache/loadable_test.go b/cache/loadable_test.go index 435ced2..7c715f3 100644 --- a/cache/loadable_test.go +++ b/cache/loadable_test.go @@ -128,6 +128,52 @@ func TestLoadableDeleteWhenError(t *testing.T) { assert.Equal(t, expectedErr, err) } +func TestLoadableInvalidate(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cache1 := &mocksCache.SetterCacheInterface{} + cache1.On("Invalidate", options).Return(nil) + + loadFunc := func(key interface{}) (interface{}, error) { + return "a value", nil + } + + cache := NewLoadable(loadFunc, cache1) + + // When + err := cache.Invalidate(options) + + // Then + assert.Nil(t, err) +} + +func TestLoadableInvalidateWhenError(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + expectedErr := errors.New("Unexpected error when invalidating data") + + cache1 := &mocksCache.SetterCacheInterface{} + cache1.On("Invalidate", options).Return(expectedErr) + + loadFunc := func(key interface{}) (interface{}, error) { + return "a value", nil + } + + cache := NewLoadable(loadFunc, cache1) + + // When + err := cache.Invalidate(options) + + // Then + assert.Equal(t, expectedErr, err) +} + func TestLoadableGetType(t *testing.T) { // Given cache1 := &mocksCache.SetterCacheInterface{} diff --git a/cache/metric.go b/cache/metric.go index 244247f..d081df5 100644 --- a/cache/metric.go +++ b/cache/metric.go @@ -42,6 +42,11 @@ func (c *MetricCache) Delete(key interface{}) error { return c.cache.Delete(key) } +// Invalidate invalidates cache item from given options +func (c *MetricCache) Invalidate(options store.InvalidateOptions) error { + return c.cache.Invalidate(options) +} + // Get obtains a value from cache and also records metrics func (c *MetricCache) updateMetrics(cache CacheInterface) { switch current := cache.(type) { diff --git a/cache/metric_test.go b/cache/metric_test.go index 2a493c9..e64eb6d 100644 --- a/cache/metric_test.go +++ b/cache/metric_test.go @@ -4,6 +4,7 @@ import ( "errors" "testing" + "github.com/eko/gocache/store" mocksCache "github.com/eko/gocache/test/mocks/cache" mocksCodec "github.com/eko/gocache/test/mocks/codec" mocksMetrics "github.com/eko/gocache/test/mocks/metrics" @@ -119,6 +120,48 @@ func TestMetricDeleteWhenError(t *testing.T) { assert.Equal(t, expectedErr, err) } +func TestMetricInvalidate(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cache1 := &mocksCache.SetterCacheInterface{} + cache1.On("Invalidate", options).Return(nil) + + metrics := &mocksMetrics.MetricsInterface{} + + cache := NewMetric(metrics, cache1) + + // When + err := cache.Invalidate(options) + + // Then + assert.Nil(t, err) +} + +func TestMetricInvalidateWhenError(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + expectedErr := errors.New("Unexpected error while invalidating data") + + cache1 := &mocksCache.SetterCacheInterface{} + cache1.On("Invalidate", options).Return(expectedErr) + + metrics := &mocksMetrics.MetricsInterface{} + + cache := NewMetric(metrics, cache1) + + // When + err := cache.Invalidate(options) + + // Then + assert.Equal(t, expectedErr, err) +} + func TestMetricGetType(t *testing.T) { // Given cache1 := &mocksCache.SetterCacheInterface{} diff --git a/codec/codec.go b/codec/codec.go index 5e6d25a..d0783e1 100644 --- a/codec/codec.go +++ b/codec/codec.go @@ -6,12 +6,14 @@ import ( // Stats allows to returns some statistics of codec usage type Stats struct { - Hits int - Miss int - SetSuccess int - SetError int - DeleteSuccess int - DeleteError int + Hits int + Miss int + SetSuccess int + SetError int + DeleteSuccess int + DeleteError int + InvalidateSuccess int + InvalidateError int } // Codec represents an instance of a cache store @@ -68,6 +70,19 @@ func (c *Codec) Delete(key interface{}) error { return err } +// Invalidate invalidates some cach items from given options +func (c *Codec) Invalidate(options store.InvalidateOptions) error { + err := c.store.Invalidate(options) + + if err == nil { + c.stats.InvalidateSuccess++ + } else { + c.stats.InvalidateError++ + } + + return err +} + // GetStore returns the store associated to this codec func (c *Codec) GetStore() store.StoreInterface { return c.store diff --git a/codec/codec_test.go b/codec/codec_test.go index a5b0631..f7cb65a 100644 --- a/codec/codec_test.go +++ b/codec/codec_test.go @@ -47,6 +47,8 @@ func TestGetWhenHit(t *testing.T) { assert.Equal(t, 0, codec.GetStats().SetError) assert.Equal(t, 0, codec.GetStats().DeleteSuccess) assert.Equal(t, 0, codec.GetStats().DeleteError) + assert.Equal(t, 0, codec.GetStats().InvalidateSuccess) + assert.Equal(t, 0, codec.GetStats().InvalidateError) } func TestGetWhenMiss(t *testing.T) { @@ -71,6 +73,8 @@ func TestGetWhenMiss(t *testing.T) { assert.Equal(t, 0, codec.GetStats().SetError) assert.Equal(t, 0, codec.GetStats().DeleteSuccess) assert.Equal(t, 0, codec.GetStats().DeleteError) + assert.Equal(t, 0, codec.GetStats().InvalidateSuccess) + assert.Equal(t, 0, codec.GetStats().InvalidateError) } func TestSetWhenSuccess(t *testing.T) { @@ -102,6 +106,8 @@ func TestSetWhenSuccess(t *testing.T) { assert.Equal(t, 0, codec.GetStats().SetError) assert.Equal(t, 0, codec.GetStats().DeleteSuccess) assert.Equal(t, 0, codec.GetStats().DeleteError) + assert.Equal(t, 0, codec.GetStats().InvalidateSuccess) + assert.Equal(t, 0, codec.GetStats().InvalidateError) } func TestSetWhenError(t *testing.T) { @@ -135,6 +141,8 @@ func TestSetWhenError(t *testing.T) { assert.Equal(t, 1, codec.GetStats().SetError) assert.Equal(t, 0, codec.GetStats().DeleteSuccess) assert.Equal(t, 0, codec.GetStats().DeleteError) + assert.Equal(t, 0, codec.GetStats().InvalidateSuccess) + assert.Equal(t, 0, codec.GetStats().InvalidateError) } func TestDeleteWhenSuccess(t *testing.T) { @@ -156,6 +164,8 @@ func TestDeleteWhenSuccess(t *testing.T) { assert.Equal(t, 0, codec.GetStats().SetError) assert.Equal(t, 1, codec.GetStats().DeleteSuccess) assert.Equal(t, 0, codec.GetStats().DeleteError) + assert.Equal(t, 0, codec.GetStats().InvalidateSuccess) + assert.Equal(t, 0, codec.GetStats().InvalidateError) } func TesDeleteWhenError(t *testing.T) { @@ -179,6 +189,64 @@ func TesDeleteWhenError(t *testing.T) { assert.Equal(t, 0, codec.GetStats().SetError) assert.Equal(t, 0, codec.GetStats().DeleteSuccess) assert.Equal(t, 1, codec.GetStats().DeleteError) + assert.Equal(t, 0, codec.GetStats().InvalidateSuccess) + assert.Equal(t, 0, codec.GetStats().InvalidateError) +} + +func TestInvalidateWhenSuccess(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + store := &mocksStore.StoreInterface{} + store.On("Invalidate", options).Return(nil) + + codec := New(store) + + // When + err := codec.Invalidate(options) + + // Then + assert.Nil(t, err) + + assert.Equal(t, 0, codec.GetStats().Hits) + assert.Equal(t, 0, codec.GetStats().Miss) + assert.Equal(t, 0, codec.GetStats().SetSuccess) + assert.Equal(t, 0, codec.GetStats().SetError) + assert.Equal(t, 0, codec.GetStats().DeleteSuccess) + assert.Equal(t, 0, codec.GetStats().DeleteError) + assert.Equal(t, 1, codec.GetStats().InvalidateSuccess) + assert.Equal(t, 0, codec.GetStats().InvalidateError) +} + +func TestInvalidateWhenError(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + expectedErr := errors.New("Unexpected error when invalidating data") + + store := &mocksStore.StoreInterface{} + store.On("Invalidate", options).Return(expectedErr) + + codec := New(store) + + // When + err := codec.Invalidate(options) + + // Then + assert.Equal(t, expectedErr, err) + + assert.Equal(t, 0, codec.GetStats().Hits) + assert.Equal(t, 0, codec.GetStats().Miss) + assert.Equal(t, 0, codec.GetStats().SetSuccess) + assert.Equal(t, 0, codec.GetStats().SetError) + assert.Equal(t, 0, codec.GetStats().DeleteSuccess) + assert.Equal(t, 0, codec.GetStats().DeleteError) + assert.Equal(t, 0, codec.GetStats().InvalidateSuccess) + assert.Equal(t, 1, codec.GetStats().InvalidateError) } func TestGetStore(t *testing.T) { diff --git a/codec/interface.go b/codec/interface.go index f6f34c4..00782ab 100644 --- a/codec/interface.go +++ b/codec/interface.go @@ -9,6 +9,7 @@ type CodecInterface interface { Get(key interface{}) (interface{}, error) Set(key interface{}, value interface{}, options *store.Options) error Delete(key interface{}) error + Invalidate(options store.InvalidateOptions) error GetStore() store.StoreInterface GetStats() *Stats diff --git a/go.mod b/go.mod index c82e743..693d52f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/eko/gocache go 1.13 require ( + github.com/allegro/bigcache v1.2.1 github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b github.com/go-redis/redis/v7 v7.0.0-beta.4 github.com/kr/pretty v0.1.0 // indirect diff --git a/go.sum b/go.sum index 7980089..407c7c1 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5Vpd github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= +github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/marshaler/marshaler.go b/marshaler/marshaler.go index 87ce907..4e4e31a 100644 --- a/marshaler/marshaler.go +++ b/marshaler/marshaler.go @@ -54,3 +54,8 @@ func (c *Marshaler) Set(key, object interface{}, options *store.Options) error { func (c *Marshaler) Delete(key interface{}) error { return c.cache.Delete(key) } + +// Invalidate invalidate cache values using given options +func (c *Marshaler) Invalidate(options store.InvalidateOptions) error { + return c.cache.Invalidate(options) +} diff --git a/marshaler/marshaler_test.go b/marshaler/marshaler_test.go index dffdfd5..d47f652 100644 --- a/marshaler/marshaler_test.go +++ b/marshaler/marshaler_test.go @@ -178,3 +178,41 @@ func TestDeleteWhenError(t *testing.T) { // Then assert.Equal(t, expectedErr, err) } + +func TestInvalidate(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cache := &mocksCache.CacheInterface{} + cache.On("Invalidate", options).Return(nil) + + marshaler := New(cache) + + // When + err := marshaler.Invalidate(options) + + // Then + assert.Nil(t, err) +} + +func TestInvalidatingWhenError(t *testing.T) { + // Given + options := store.InvalidateOptions{ + Tags: []string{"tag1"}, + } + + expectedErr := errors.New("Unexpected error when invalidating data") + + cache := &mocksCache.CacheInterface{} + cache.On("Invalidate", options).Return(expectedErr) + + marshaler := New(cache) + + // When + err := marshaler.Invalidate(options) + + // Then + assert.Equal(t, expectedErr, err) +} diff --git a/metrics/prometheus.go b/metrics/prometheus.go index 5a4b24c..470be58 100644 --- a/metrics/prometheus.go +++ b/metrics/prometheus.go @@ -51,4 +51,7 @@ func (m *Prometheus) RecordFromCodec(codec codec.CodecInterface) { m.Record(storeType, "delete_success", float64(stats.DeleteSuccess)) m.Record(storeType, "delete_error", float64(stats.DeleteError)) + + m.Record(storeType, "invalidate_success", float64(stats.InvalidateSuccess)) + m.Record(storeType, "invalidate_error", float64(stats.InvalidateError)) } diff --git a/metrics/prometheus_test.go b/metrics/prometheus_test.go index 6030824..68feb6b 100644 --- a/metrics/prometheus_test.go +++ b/metrics/prometheus_test.go @@ -46,12 +46,14 @@ func TestRecordFromCodec(t *testing.T) { redisStore.On("GetType").Return("redis") stats := &codec.Stats{ - Hits: 4, - Miss: 6, - SetSuccess: 12, - SetError: 3, - DeleteSuccess: 8, - DeleteError: 5, + Hits: 4, + Miss: 6, + SetSuccess: 12, + SetError: 3, + DeleteSuccess: 8, + DeleteError: 5, + InvalidateSuccess: 2, + InvalidateError: 1, } testCodec := &mocksCodec.CodecInterface{} @@ -92,6 +94,14 @@ func TestRecordFromCodec(t *testing.T) { metricName: "delete_error", expected: float64(stats.DeleteError), }, + { + metricName: "invalidate_success", + expected: float64(stats.InvalidateSuccess), + }, + { + metricName: "invalidate_error", + expected: float64(stats.InvalidateError), + }, } for _, tc := range testCases { diff --git a/store/bigcache.go b/store/bigcache.go index 4463bd3..4e5ed9f 100644 --- a/store/bigcache.go +++ b/store/bigcache.go @@ -2,6 +2,9 @@ package store import ( "errors" + "fmt" + "strings" + time "time" ) // BigcacheClientInterface represents a allegro/bigcache client @@ -12,7 +15,8 @@ type BigcacheClientInterface interface { } const ( - BigcacheType = "bigcache" + BigcacheType = "bigcache" + BigcacheTagPattern = "gocache_tag_%s" ) // BigcacheStore is a store for Redis @@ -46,16 +50,82 @@ func (s *BigcacheStore) Get(key interface{}) (interface{}, error) { return item, err } -// Set defines data in Redis for given key idntifier +// Set defines data in Redis for given key identifier func (s *BigcacheStore) Set(key interface{}, value interface{}, options *Options) error { - return s.client.Set(key.(string), value.([]byte)) + if options == nil { + options = s.options + } + + err := s.client.Set(key.(string), value.([]byte)) + if err != nil { + return err + } + + if tags := options.TagsValue(); len(tags) > 0 { + s.setTags(key, tags) + } + + return nil } -// Delete removes data from Redis for given key idntifier +func (s *BigcacheStore) setTags(key interface{}, tags []string) { + for _, tag := range tags { + var tagKey = fmt.Sprintf(BigcacheTagPattern, tag) + var cacheKeys = []string{} + + if result, err := s.Get(tagKey); err == nil { + if bytes, ok := result.([]byte); ok { + cacheKeys = strings.Split(string(bytes), ",") + } + } + + var alreadyInserted = false + for _, cacheKey := range cacheKeys { + if cacheKey == key.(string) { + alreadyInserted = true + break + } + } + + if !alreadyInserted { + cacheKeys = append(cacheKeys, key.(string)) + } + + s.Set(tagKey, []byte(strings.Join(cacheKeys, ",")), &Options{ + Expiration: 720 * time.Hour, + }) + } +} + +// Delete removes data from Redis for given key identifier func (s *BigcacheStore) Delete(key interface{}) error { return s.client.Delete(key.(string)) } +// Invalidate invalidates some cache data in Redis for given options +func (s *BigcacheStore) Invalidate(options InvalidateOptions) error { + if tags := options.TagsValue(); len(tags) > 0 { + for _, tag := range tags { + var tagKey = fmt.Sprintf(BigcacheTagPattern, tag) + result, err := s.Get(tagKey) + if err != nil { + return nil + } + + var cacheKeys = []string{} + if bytes, ok := result.([]byte); ok { + cacheKeys = strings.Split(string(bytes), ",") + } + + for _, cacheKey := range cacheKeys { + s.Delete(cacheKey) + } + } + } + + return nil +} + // GetType returns the store type func (s *BigcacheStore) GetType() string { return BigcacheType diff --git a/store/bigcache_test.go b/store/bigcache_test.go index 2d91c38..a1f4ff4 100644 --- a/store/bigcache_test.go +++ b/store/bigcache_test.go @@ -55,6 +55,25 @@ func TestBigcacheSet(t *testing.T) { assert.Nil(t, err) } +func TestBigcacheSetWithTags(t *testing.T) { + // Given + cacheKey := "my-key" + cacheValue := []byte("my-cache-value") + + client := &MockBigcacheClientInterface{} + client.On("Set", cacheKey, cacheValue).Return(nil) + client.On("Get", "gocache_tag_tag1").Return(nil, nil) + client.On("Set", "gocache_tag_tag1", []byte("my-key")).Return(nil) + + store := NewBigcache(client, nil) + + // When + err := store.Set(cacheKey, cacheValue, &Options{Tags: []string{"tag1"}}) + + // Then + assert.Nil(t, err) +} + func TestBigcacheDelete(t *testing.T) { // Given cacheKey := "my-key" @@ -89,6 +108,50 @@ func TestBigcacheDeleteWhenError(t *testing.T) { assert.Equal(t, expectedErr, err) } +func TestBigcacheInvalidate(t *testing.T) { + // Given + options := InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cacheKeys := []byte("a23fdf987h2svc23,jHG2372x38hf74") + + client := &MockBigcacheClientInterface{} + client.On("Get", "gocache_tag_tag1").Return(cacheKeys, nil) + client.On("Delete", "a23fdf987h2svc23").Return(nil) + client.On("Delete", "jHG2372x38hf74").Return(nil) + + store := NewBigcache(client, nil) + + // When + err := store.Invalidate(options) + + // Then + assert.Nil(t, err) +} + +func TestBigcacheInvalidateWhenError(t *testing.T) { + // Given + options := InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cacheKeys := []byte("a23fdf987h2svc23,jHG2372x38hf74") + + client := &MockBigcacheClientInterface{} + client.On("Get", "gocache_tag_tag1").Return(cacheKeys, nil) + client.On("Delete", "a23fdf987h2svc23").Return(errors.New("Unexpected error")) + client.On("Delete", "jHG2372x38hf74").Return(nil) + + store := NewBigcache(client, nil) + + // When + err := store.Invalidate(options) + + // Then + assert.Nil(t, err) +} + func TestBigcacheGetType(t *testing.T) { // Given client := &MockBigcacheClientInterface{} diff --git a/store/interface.go b/store/interface.go index 919d0ce..9ddc4c2 100644 --- a/store/interface.go +++ b/store/interface.go @@ -5,5 +5,6 @@ type StoreInterface interface { Get(key interface{}) (interface{}, error) Set(key interface{}, value interface{}, options *Options) error Delete(key interface{}) error + Invalidate(options InvalidateOptions) error GetType() string } diff --git a/store/invalidate_options.go b/store/invalidate_options.go new file mode 100644 index 0000000..7abcb7c --- /dev/null +++ b/store/invalidate_options.go @@ -0,0 +1,12 @@ +package store + +// InvalidateOptions represents the cache invalidation available options +type InvalidateOptions struct { + // Tags allows to specify associated tags to the current value + Tags []string +} + +// TagsValue returns the tags option value +func (o InvalidateOptions) TagsValue() []string { + return o.Tags +} diff --git a/store/invalidate_options_test.go b/store/invalidate_options_test.go new file mode 100644 index 0000000..69608de --- /dev/null +++ b/store/invalidate_options_test.go @@ -0,0 +1,17 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInvalidateOptionsTagsValue(t *testing.T) { + // Given + options := InvalidateOptions{ + Tags: []string{"tag1", "tag2", "tag3"}, + } + + // When - Then + assert.Equal(t, []string{"tag1", "tag2", "tag3"}, options.TagsValue()) +} diff --git a/store/memcache.go b/store/memcache.go index 1fe3040..33b3821 100644 --- a/store/memcache.go +++ b/store/memcache.go @@ -2,6 +2,9 @@ package store import ( "errors" + "fmt" + "strings" + time "time" "github.com/bradfitz/gomemcache/memcache" ) @@ -14,7 +17,8 @@ type MemcacheClientInterface interface { } const ( - MemcacheType = "memcache" + MemcacheType = "memcache" + MemcacheTagPattern = "gocache_tag_%s" ) // MemcacheStore is a store for Memcache @@ -48,7 +52,7 @@ func (s *MemcacheStore) Get(key interface{}) (interface{}, error) { return item.Value, err } -// Set defines data in Memcache for given key idntifier +// Set defines data in Memcache for given key identifier func (s *MemcacheStore) Set(key interface{}, value interface{}, options *Options) error { if options == nil { options = s.options @@ -60,14 +64,76 @@ func (s *MemcacheStore) Set(key interface{}, value interface{}, options *Options Expiration: int32(options.ExpirationValue().Seconds()), } - return s.client.Set(item) + err := s.client.Set(item) + if err != nil { + return err + } + + if tags := options.TagsValue(); len(tags) > 0 { + s.setTags(key, tags) + } + + return nil +} + +func (s *MemcacheStore) setTags(key interface{}, tags []string) { + for _, tag := range tags { + var tagKey = fmt.Sprintf(MemcacheTagPattern, tag) + var cacheKeys = []string{} + + if result, err := s.Get(tagKey); err == nil { + if bytes, ok := result.([]byte); ok { + cacheKeys = strings.Split(string(bytes), ",") + } + } + + var alreadyInserted = false + for _, cacheKey := range cacheKeys { + if cacheKey == key.(string) { + alreadyInserted = true + break + } + } + + if !alreadyInserted { + cacheKeys = append(cacheKeys, key.(string)) + } + + s.Set(tagKey, []byte(strings.Join(cacheKeys, ",")), &Options{ + Expiration: 720 * time.Hour, + }) + } } -// Delete removes data from Memcache for given key idntifier +// Delete removes data from Memcache for given key identifier func (s *MemcacheStore) Delete(key interface{}) error { return s.client.Delete(key.(string)) } +// Invalidate invalidates some cache data in Redis for given options +func (s *MemcacheStore) Invalidate(options InvalidateOptions) error { + if tags := options.TagsValue(); len(tags) > 0 { + for _, tag := range tags { + var tagKey = fmt.Sprintf(MemcacheTagPattern, tag) + result, err := s.Get(tagKey) + if err != nil { + return nil + } + + var cacheKeys = []string{} + if bytes, ok := result.([]byte); ok { + cacheKeys = strings.Split(string(bytes), ",") + } + + for _, cacheKey := range cacheKeys { + s.Delete(cacheKey) + } + } + } + + return nil +} + // GetType returns the store type func (s *MemcacheStore) GetType() string { return MemcacheType diff --git a/store/memcache_test.go b/store/memcache_test.go index 59cc797..f0e5c43 100644 --- a/store/memcache_test.go +++ b/store/memcache_test.go @@ -7,6 +7,7 @@ import ( "github.com/bradfitz/gomemcache/memcache" "github.com/stretchr/testify/assert" + mock "github.com/stretchr/testify/mock" ) func TestNewMemcache(t *testing.T) { @@ -70,6 +71,25 @@ func TestMemcacheSet(t *testing.T) { assert.Nil(t, err) } +func TestMemcacheSetWithTags(t *testing.T) { + // Given + cacheKey := "my-key" + cacheValue := []byte("my-cache-value") + + client := &MockMemcacheClientInterface{} + client.On("Set", mock.Anything).Return(nil) + client.On("Get", "gocache_tag_tag1").Return(nil, nil) + + store := NewMemcache(client, nil) + + // When + err := store.Set(cacheKey, cacheValue, &Options{Tags: []string{"tag1"}}) + + // Then + assert.Nil(t, err) + client.AssertNumberOfCalls(t, "Set", 2) +} + func TestMemcacheDelete(t *testing.T) { // Given cacheKey := "my-key" @@ -104,6 +124,54 @@ func TestMemcacheDeleteWhenError(t *testing.T) { assert.Equal(t, expectedErr, err) } +func TestMemcacheInvalidate(t *testing.T) { + // Given + options := InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cacheKeys := &memcache.Item{ + Value: []byte("a23fdf987h2svc23,jHG2372x38hf74"), + } + + client := &MockMemcacheClientInterface{} + client.On("Get", "gocache_tag_tag1").Return(cacheKeys, nil) + client.On("Delete", "a23fdf987h2svc23").Return(nil) + client.On("Delete", "jHG2372x38hf74").Return(nil) + + store := NewMemcache(client, nil) + + // When + err := store.Invalidate(options) + + // Then + assert.Nil(t, err) +} + +func TestMemcacheInvalidateWhenError(t *testing.T) { + // Given + options := InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cacheKeys := &memcache.Item{ + Value: []byte("a23fdf987h2svc23,jHG2372x38hf74"), + } + + client := &MockMemcacheClientInterface{} + client.On("Get", "gocache_tag_tag1").Return(cacheKeys, nil) + client.On("Delete", "a23fdf987h2svc23").Return(errors.New("Unexpected error")) + client.On("Delete", "jHG2372x38hf74").Return(nil) + + store := NewMemcache(client, nil) + + // When + err := store.Invalidate(options) + + // Then + assert.Nil(t, err) +} + func TestMemcacheGetType(t *testing.T) { // Given client := &MockMemcacheClientInterface{} diff --git a/store/options.go b/store/options.go index c5fa66b..1ab1f7c 100644 --- a/store/options.go +++ b/store/options.go @@ -12,6 +12,9 @@ type Options struct { // Expiration allows to specify an expiration time when setting a value Expiration time.Duration + + // Tags allows to specify associated tags to the current value + Tags []string } // CostValue returns the allocated memory capacity @@ -23,3 +26,8 @@ func (o Options) CostValue() int64 { func (o Options) ExpirationValue() time.Duration { return o.Expiration } + +// TagsValue returns the tags option value +func (o Options) TagsValue() []string { + return o.Tags +} diff --git a/store/options_test.go b/store/options_test.go index 701a4dc..2509c51 100644 --- a/store/options_test.go +++ b/store/options_test.go @@ -26,3 +26,13 @@ func TestOptionsExpirationValue(t *testing.T) { // When - Then assert.Equal(t, 25*time.Second, options.ExpirationValue()) } + +func TestOptionsTagsValue(t *testing.T) { + // Given + options := Options{ + Tags: []string{"tag1", "tag2", "tag3"}, + } + + // When - Then + assert.Equal(t, []string{"tag1", "tag2", "tag3"}, options.TagsValue()) +} diff --git a/store/redis.go b/store/redis.go index ad881b8..b3b3595 100644 --- a/store/redis.go +++ b/store/redis.go @@ -1,6 +1,8 @@ package store import ( + "fmt" + "strings" "time" "github.com/go-redis/redis/v7" @@ -14,7 +16,8 @@ type RedisClientInterface interface { } const ( - RedisType = "redis" + RedisType = "redis" + RedisTagPattern = "gocache_tag_%s" ) // RedisStore is a store for Redis @@ -40,21 +43,83 @@ func (s *RedisStore) Get(key interface{}) (interface{}, error) { return s.client.Get(key.(string)).Result() } -// Set defines data in Redis for given key idntifier +// Set defines data in Redis for given key identifier func (s *RedisStore) Set(key interface{}, value interface{}, options *Options) error { if options == nil { options = s.options } - return s.client.Set(key.(string), value, options.ExpirationValue()).Err() + err := s.client.Set(key.(string), value, options.ExpirationValue()).Err() + if err != nil { + return err + } + + if tags := options.TagsValue(); len(tags) > 0 { + s.setTags(key, tags) + } + + return nil } -// Delete removes data from Redis for given key idntifier +func (s *RedisStore) setTags(key interface{}, tags []string) { + for _, tag := range tags { + var tagKey = fmt.Sprintf(RedisTagPattern, tag) + var cacheKeys = []string{} + + if result, err := s.Get(tagKey); err == nil { + if bytes, ok := result.([]byte); ok { + cacheKeys = strings.Split(string(bytes), ",") + } + } + + var alreadyInserted = false + for _, cacheKey := range cacheKeys { + if cacheKey == key.(string) { + alreadyInserted = true + break + } + } + + if !alreadyInserted { + cacheKeys = append(cacheKeys, key.(string)) + } + + s.Set(tagKey, []byte(strings.Join(cacheKeys, ",")), &Options{ + Expiration: 720 * time.Hour, + }) + } +} + +// Delete removes data from Redis for given key identifier func (s *RedisStore) Delete(key interface{}) error { _, err := s.client.Del(key.(string)).Result() return err } +// Invalidate invalidates some cache data in Redis for given options +func (s *RedisStore) Invalidate(options InvalidateOptions) error { + if tags := options.TagsValue(); len(tags) > 0 { + for _, tag := range tags { + var tagKey = fmt.Sprintf(RedisTagPattern, tag) + result, err := s.Get(tagKey) + if err != nil { + return nil + } + + var cacheKeys = []string{} + if bytes, ok := result.([]byte); ok { + cacheKeys = strings.Split(string(bytes), ",") + } + + for _, cacheKey := range cacheKeys { + s.Delete(cacheKey) + } + } + } + + return nil +} + // GetType returns the store type func (s *RedisStore) GetType() string { return RedisType diff --git a/store/redis_test.go b/store/redis_test.go index 2a3ac32..04e737b 100644 --- a/store/redis_test.go +++ b/store/redis_test.go @@ -61,6 +61,25 @@ func TestRedisSet(t *testing.T) { assert.Nil(t, err) } +func TestRedisSetWithTags(t *testing.T) { + // Given + cacheKey := "my-key" + cacheValue := []byte("my-cache-value") + + client := &MockRedisClientInterface{} + client.On("Set", cacheKey, cacheValue, time.Duration(0)).Return(&redis.StatusCmd{}) + client.On("Get", "gocache_tag_tag1").Return(&redis.StringCmd{}) + client.On("Set", "gocache_tag_tag1", []byte("my-key"), 720*time.Hour).Return(&redis.StatusCmd{}) + + store := NewRedis(client, nil) + + // When + err := store.Set(cacheKey, cacheValue, &Options{Tags: []string{"tag1"}}) + + // Then + assert.Nil(t, err) +} + func TestRedisDelete(t *testing.T) { // Given cacheKey := "my-key" @@ -77,6 +96,26 @@ func TestRedisDelete(t *testing.T) { assert.Nil(t, err) } +func TestRedisInvalidate(t *testing.T) { + // Given + options := InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cacheKeys := &redis.StringCmd{} + + client := &MockRedisClientInterface{} + client.On("Get", "gocache_tag_tag1").Return(cacheKeys, nil) + + store := NewRedis(client, nil) + + // When + err := store.Invalidate(options) + + // Then + assert.Nil(t, err) +} + func TestRedisGetType(t *testing.T) { // Given client := &MockRedisClientInterface{} diff --git a/store/ristretto.go b/store/ristretto.go index 05c2075..c20d414 100644 --- a/store/ristretto.go +++ b/store/ristretto.go @@ -3,10 +3,13 @@ package store import ( "errors" "fmt" + "strings" + time "time" ) const ( - RistrettoType = "ristretto" + RistrettoType = "ristretto" + RistrettoTagPattern = "gocache_tag_%s" ) // RistrettoClientInterface represents a dgraph-io/ristretto client @@ -46,7 +49,7 @@ func (s *RistrettoStore) Get(key interface{}) (interface{}, error) { return value, err } -// Set defines data in Ristretto memoey cache for given key idntifier +// Set defines data in Ristretto memoey cache for given key identifier func (s *RistrettoStore) Set(key interface{}, value interface{}, options *Options) error { var err error @@ -58,15 +61,76 @@ func (s *RistrettoStore) Set(key interface{}, value interface{}, options *Option err = fmt.Errorf("An error has occured while setting value '%v' on key '%v'", value, key) } - return err + if err != nil { + return err + } + + if tags := options.TagsValue(); len(tags) > 0 { + s.setTags(key, tags) + } + + return nil } -// Delete removes data in Ristretto memoey cache for given key idntifier +func (s *RistrettoStore) setTags(key interface{}, tags []string) { + for _, tag := range tags { + var tagKey = fmt.Sprintf(RistrettoTagPattern, tag) + var cacheKeys = []string{} + + if result, err := s.Get(tagKey); err == nil { + if bytes, ok := result.([]byte); ok { + cacheKeys = strings.Split(string(bytes), ",") + } + } + + var alreadyInserted = false + for _, cacheKey := range cacheKeys { + if cacheKey == key.(string) { + alreadyInserted = true + break + } + } + + if !alreadyInserted { + cacheKeys = append(cacheKeys, key.(string)) + } + + s.Set(tagKey, []byte(strings.Join(cacheKeys, ",")), &Options{ + Expiration: 720 * time.Hour, + }) + } +} + +// Delete removes data in Ristretto memoey cache for given key identifier func (s *RistrettoStore) Delete(key interface{}) error { s.client.Del(key) return nil } +// Invalidate invalidates some cache data in Redis for given options +func (s *RistrettoStore) Invalidate(options InvalidateOptions) error { + if tags := options.TagsValue(); len(tags) > 0 { + for _, tag := range tags { + var tagKey = fmt.Sprintf(RistrettoTagPattern, tag) + result, err := s.Get(tagKey) + if err != nil { + return nil + } + + var cacheKeys = []string{} + if bytes, ok := result.([]byte); ok { + cacheKeys = strings.Split(string(bytes), ",") + } + + for _, cacheKey := range cacheKeys { + s.Delete(cacheKey) + } + } + } + + return nil +} + // GetType returns the store type func (s *RistrettoStore) GetType() string { return RistrettoType diff --git a/store/ristretto_test.go b/store/ristretto_test.go index aa7b45a..17c1111 100644 --- a/store/ristretto_test.go +++ b/store/ristretto_test.go @@ -62,6 +62,25 @@ func TestRistrettoSet(t *testing.T) { assert.Nil(t, err) } +func TestRistrettoSetWithTags(t *testing.T) { + // Given + cacheKey := "my-key" + cacheValue := []byte("my-cache-value") + + client := &MockRistrettoClientInterface{} + client.On("Set", cacheKey, cacheValue, int64(0)).Return(true) + client.On("Get", "gocache_tag_tag1").Return(nil, true) + client.On("Set", "gocache_tag_tag1", []byte("my-key"), int64(0)).Return(true) + + store := NewRistretto(client, nil) + + // When + err := store.Set(cacheKey, cacheValue, &Options{Tags: []string{"tag1"}}) + + // Then + assert.Nil(t, err) +} + func TestRistrettoDelete(t *testing.T) { // Given cacheKey := "my-key" @@ -78,6 +97,50 @@ func TestRistrettoDelete(t *testing.T) { assert.Nil(t, err) } +func TestRistrettoInvalidate(t *testing.T) { + // Given + options := InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cacheKeys := []byte("a23fdf987h2svc23,jHG2372x38hf74") + + client := &MockRistrettoClientInterface{} + client.On("Get", "gocache_tag_tag1").Return(cacheKeys, true) + client.On("Del", "a23fdf987h2svc23").Return(nil) + client.On("Del", "jHG2372x38hf74").Return(nil) + + store := NewRistretto(client, nil) + + // When + err := store.Invalidate(options) + + // Then + assert.Nil(t, err) +} + +func TestRistrettoInvalidateWhenError(t *testing.T) { + // Given + options := InvalidateOptions{ + Tags: []string{"tag1"}, + } + + cacheKeys := []byte("a23fdf987h2svc23,jHG2372x38hf74") + + client := &MockRistrettoClientInterface{} + client.On("Get", "gocache_tag_tag1").Return(cacheKeys, false) + client.On("Del", "a23fdf987h2svc23").Return(nil) + client.On("Del", "jHG2372x38hf74").Return(nil) + + store := NewRistretto(client, nil) + + // When + err := store.Invalidate(options) + + // Then + assert.Nil(t, err) +} + func TestRistrettoGetType(t *testing.T) { // Given client := &MockRistrettoClientInterface{} diff --git a/test/mocks/cache/cache_interface.go b/test/mocks/cache/cache_interface.go index e52f8a4..41ddb2a 100644 --- a/test/mocks/cache/cache_interface.go +++ b/test/mocks/cache/cache_interface.go @@ -61,6 +61,20 @@ func (_m *CacheInterface) GetType() string { return r0 } +// Invalidate provides a mock function with given fields: options +func (_m *CacheInterface) Invalidate(options store.InvalidateOptions) error { + ret := _m.Called(options) + + var r0 error + if rf, ok := ret.Get(0).(func(store.InvalidateOptions) error); ok { + r0 = rf(options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Set provides a mock function with given fields: key, object, options func (_m *CacheInterface) Set(key interface{}, object interface{}, options *store.Options) error { ret := _m.Called(key, object, options) diff --git a/test/mocks/cache/setter_cache_interface.go b/test/mocks/cache/setter_cache_interface.go index 15b69ca..787bbe9 100644 --- a/test/mocks/cache/setter_cache_interface.go +++ b/test/mocks/cache/setter_cache_interface.go @@ -78,6 +78,20 @@ func (_m *SetterCacheInterface) GetType() string { return r0 } +// Invalidate provides a mock function with given fields: options +func (_m *SetterCacheInterface) Invalidate(options store.InvalidateOptions) error { + ret := _m.Called(options) + + var r0 error + if rf, ok := ret.Get(0).(func(store.InvalidateOptions) error); ok { + r0 = rf(options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Set provides a mock function with given fields: key, object, options func (_m *SetterCacheInterface) Set(key interface{}, object interface{}, options *store.Options) error { ret := _m.Called(key, object, options) diff --git a/test/mocks/codec/codec_interface.go b/test/mocks/codec/codec_interface.go index e4131bc..a46fa69 100644 --- a/test/mocks/codec/codec_interface.go +++ b/test/mocks/codec/codec_interface.go @@ -80,6 +80,20 @@ func (_m *CodecInterface) GetStore() store.StoreInterface { return r0 } +// Invalidate provides a mock function with given fields: options +func (_m *CodecInterface) Invalidate(options store.InvalidateOptions) error { + ret := _m.Called(options) + + var r0 error + if rf, ok := ret.Get(0).(func(store.InvalidateOptions) error); ok { + r0 = rf(options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Set provides a mock function with given fields: key, value, options func (_m *CodecInterface) Set(key interface{}, value interface{}, options *store.Options) error { ret := _m.Called(key, value, options) diff --git a/test/mocks/store/store_interface.go b/test/mocks/store/store_interface.go index 70c32b4..4708146 100644 --- a/test/mocks/store/store_interface.go +++ b/test/mocks/store/store_interface.go @@ -61,6 +61,20 @@ func (_m *StoreInterface) GetType() string { return r0 } +// Invalidate provides a mock function with given fields: options +func (_m *StoreInterface) Invalidate(options store.InvalidateOptions) error { + ret := _m.Called(options) + + var r0 error + if rf, ok := ret.Get(0).(func(store.InvalidateOptions) error); ok { + r0 = rf(options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Set provides a mock function with given fields: key, value, options func (_m *StoreInterface) Set(key interface{}, value interface{}, options *store.Options) error { ret := _m.Called(key, value, options)