diff --git a/xload/providers/cached/cache.go b/xload/providers/cached/cache.go new file mode 100644 index 0000000..e5a0434 --- /dev/null +++ b/xload/providers/cached/cache.go @@ -0,0 +1,68 @@ +package cached + +import ( + "sync" + "time" +) + +// Cacher is the interface for custom cache implementations. +// +//go:generate mockery --name Cacher --structname MockCache --filename mock_test.go --outpkg cached --output . +type Cacher interface { + Get(key string) (string, error) + Set(key, value string, ttl time.Duration) error +} + +type mv struct { + val string + ttl time.Time +} + +// MapCache is a simple cache implementation using a map. +type MapCache struct { + m sync.Map + + now func() time.Time +} + +// NewMapCache returns a new MapCache. +func NewMapCache() *MapCache { + return &MapCache{ + m: sync.Map{}, + now: time.Now, + } +} + +// Get returns the value for the given key, if cached. +func (c *MapCache) Get(key string) (string, error) { + v, ok := c.m.Load(key) + if !ok { + return "", nil + } + + mv, ok := v.(*mv) + + if !ok || c.now().After(mv.ttl) { + c.delete(key) + + return "", nil + } + + return mv.val, nil +} + +// Set sets the value for the given key. +func (c *MapCache) Set(key, value string, ttl time.Duration) error { + v := &mv{ + val: value, + ttl: c.now().Add(ttl), + } + + c.m.Store(key, v) + + return nil +} + +func (c *MapCache) delete(key string) { + c.m.Delete(key) +} diff --git a/xload/providers/cached/cache_test.go b/xload/providers/cached/cache_test.go new file mode 100644 index 0000000..42f20ed --- /dev/null +++ b/xload/providers/cached/cache_test.go @@ -0,0 +1,43 @@ +package cached + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMapCache(t *testing.T) { + cache := NewMapCache() + + err := cache.Set("foo", "bar", 1*time.Minute) + assert.NoError(t, err) + + v, err := cache.Get("foo") + assert.NoError(t, err) + assert.Equal(t, "bar", v) + + v, err = cache.Get("not-found") + assert.NoError(t, err) + assert.Equal(t, "", v) +} + +func TestMapCacheExpired(t *testing.T) { + cache := NewMapCache() + now := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + + cache.now = func() time.Time { + return now + } + + err := cache.Set("foo", "bar", 1*time.Minute) + assert.NoError(t, err) + + cache.now = func() time.Time { + return now.Add(2 * time.Minute) + } + + v, err := cache.Get("foo") + assert.NoError(t, err) + assert.Equal(t, "", v) +} diff --git a/xload/providers/cached/doc.go b/xload/providers/cached/doc.go new file mode 100644 index 0000000..9ced968 --- /dev/null +++ b/xload/providers/cached/doc.go @@ -0,0 +1,6 @@ +// Package cached provides a cached provider for xload. +// This loader can be used to cache the results of any loader. +// +// Useful for loaders that have a high latency, or loaders that are +// called frequently. +package cached diff --git a/xload/providers/cached/example_test.go b/xload/providers/cached/example_test.go new file mode 100644 index 0000000..169e594 --- /dev/null +++ b/xload/providers/cached/example_test.go @@ -0,0 +1,113 @@ +package cached_test + +import ( + "context" + "time" + + "github.com/gojekfarm/xtools/xload" + "github.com/gojekfarm/xtools/xload/providers/cached" +) + +func Example() { + // This example shows how to use the cached loader + // with a remote loader. + + ctx := context.Background() + cfg := struct { + Title string `env:"TITLE"` + Link string `env:"LINK"` + ButtonLabel string `env:"BUTTON_LABEL"` + }{} + + remoteLoader := xload.LoaderFunc(func(ctx context.Context, key string) (string, error) { + // Load the value from a remote source. + + return "", nil + }) + + err := xload.Load( + ctx, &cfg, + cached.NewLoader( + remoteLoader, + cached.TTL(5*60*time.Minute), + ), + ) + if err != nil { + panic(err) + } +} + +type CustomCache struct{} + +func NewCustomCache() *CustomCache { + return &CustomCache{} +} + +func (c *CustomCache) Get(key string) (string, error) { + return "", nil +} + +func (c *CustomCache) Set(key, value string, ttl time.Duration) error { + return nil +} + +func Example_customCache() { + // This example shows how to use a custom cache + // with the cached loader. + + ctx := context.Background() + cfg := struct { + Title string `env:"TITLE"` + Link string `env:"LINK"` + ButtonLabel string `env:"BUTTON_LABEL"` + }{} + + remoteLoader := xload.LoaderFunc(func(ctx context.Context, key string) (string, error) { + // Load the value from a remote source. + + return "", nil + }) + + err := xload.Load( + ctx, &cfg, + cached.NewLoader( + remoteLoader, + cached.TTL(5*60*time.Minute), + cached.Cache(NewCustomCache()), + ), + ) + if err != nil { + panic(err) + } +} + +func Example_disableEmptyValueHit() { + // By default, the cached loader caches empty values. + // This example shows how to disable caching of empty values + // with the cached loader. + + ctx := context.Background() + cfg := struct { + Title string `env:"TITLE"` + Link string `env:"LINK"` + ButtonLabel string `env:"BUTTON_LABEL"` + }{} + + remoteLoader := xload.LoaderFunc(func(ctx context.Context, key string) (string, error) { + // Load the value from a remote source. + + return "", nil + }) + + err := xload.Load( + ctx, &cfg, + cached.NewLoader( + remoteLoader, + cached.TTL(5*60*time.Minute), + cached.DisableEmptyValueHit, + ), + ) + if err != nil { + panic(err) + } +} diff --git a/xload/providers/cached/go.mod b/xload/providers/cached/go.mod new file mode 100644 index 0000000..c6f5fd5 --- /dev/null +++ b/xload/providers/cached/go.mod @@ -0,0 +1,21 @@ +module github.com/gojekfarm/xtools/xload/providers/cached + +go 1.20 + +replace github.com/gojekfarm/xtools/xload => ../.. + +require ( + github.com/gojekfarm/xtools/xload v0.0.0-00010101000000-000000000000 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/stretchr/objx v0.5.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/xload/providers/cached/go.sum b/xload/providers/cached/go.sum new file mode 100644 index 0000000..d7bed1f --- /dev/null +++ b/xload/providers/cached/go.sum @@ -0,0 +1,33 @@ +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/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/gotidy/ptr v1.4.0 h1:7++suUs+HNHMnyz6/AW3SE+4EnBhupPSQTSI7QNijVc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/xload/providers/cached/loader.go b/xload/providers/cached/loader.go new file mode 100644 index 0000000..369aab6 --- /dev/null +++ b/xload/providers/cached/loader.go @@ -0,0 +1,43 @@ +package cached + +import ( + "context" + + "github.com/gojekfarm/xtools/xload" +) + +// NewLoader returns a new cached loader. +// +// The cached loader uses these defaults: +// - TTL: 5 minutes. Configurable via `TTL` option. +// - Cache: A simple unbounded map cache. Configurable via `Cache` option. +// - Empty value hit: Enabled. Configurable via `DisableEmptyValueHit` option. +func NewLoader(l xload.Loader, opts ...Option) xload.LoaderFunc { + o := defaultOptions() + + o.apply(opts...) + + return xload.LoaderFunc(func(ctx context.Context, key string) (string, error) { + v, err := o.cache.Get(key) + if err != nil { + return "", err + } + + if v != "" { + return v, nil + } + + loaded, err := l.Load(ctx, key) + if err != nil { + return "", err + } + + if loaded == "" && !o.emptyHit { + return "", nil + } + + err = o.cache.Set(key, loaded, o.ttl) + + return loaded, err + }) +} diff --git a/xload/providers/cached/loader_test.go b/xload/providers/cached/loader_test.go new file mode 100644 index 0000000..9783872 --- /dev/null +++ b/xload/providers/cached/loader_test.go @@ -0,0 +1,145 @@ +package cached + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/gojekfarm/xtools/xload" +) + +type config struct { + Key1 string `env:"KEY_1"` + Key2 string `env:"KEY_2"` + Key3 string `env:"KEY_3"` +} + +func TestNewLoader(t *testing.T) { + loader := xload.MapLoader(map[string]string{ + "KEY_1": "value-1", + "KEY_2": "value-2", + }) + + cachedLoader := NewLoader(loader) + + t.Run("Cache MISS", func(t *testing.T) { + cfg := config{} + + err := xload.Load(context.TODO(), &cfg, cachedLoader) + assert.NoError(t, err) + assert.Equal(t, "value-1", cfg.Key1) + assert.Equal(t, "value-2", cfg.Key2) + }) + + t.Run("Cache HIT", func(t *testing.T) { + cfg := config{} + + err := xload.Load(context.TODO(), &cfg, cachedLoader) + assert.NoError(t, err) + assert.Equal(t, "value-1", cfg.Key1) + assert.Equal(t, "value-2", cfg.Key2) + }) +} + +func TestNewLoader_WithTTL(t *testing.T) { + loader := xload.MapLoader(map[string]string{ + "KEY_1": "value-1", + "KEY_2": "value-2", + }) + + ttl := 123 * time.Second + + mc := NewMockCache(t) + mc.On("Get", mock.Anything).Return("", nil).Times(3) + mc.On("Set", mock.Anything, mock.Anything, ttl).Return(nil).Times(3) + + cachedLoader := NewLoader(loader, TTL(ttl), Cache(mc)) + + cfg := config{} + + err := xload.Load(context.Background(), &cfg, cachedLoader) + assert.NoError(t, err) + + mc.AssertExpectations(t) +} + +func TestNewLoader_WithDisableEmptyValueHit(t *testing.T) { + loader := xload.MapLoader(map[string]string{ + "KEY_1": "value-1", + "KEY_2": "value-2", + }) + + ttl := 123 * time.Second + + mc := NewMockCache(t) + mc.On("Get", mock.Anything).Return("", nil).Times(6) + mc.On("Set", mock.Anything, mock.Anything, ttl).Return(nil).Times(4) + + cachedLoader := NewLoader(loader, TTL(ttl), Cache(mc), DisableEmptyValueHit) + + cfg := config{} + + err := xload.Load(context.Background(), &cfg, cachedLoader) + assert.NoError(t, err) + + // load again to ensure that the empty value is not cached + err = xload.Load(context.Background(), &cfg, cachedLoader) + assert.NoError(t, err) + + mc.AssertExpectations(t) +} + +func TestNewLoader_ForwardError(t *testing.T) { + failingLoader := xload.LoaderFunc(func(ctx context.Context, key string) (string, error) { + return "", assert.AnError + }) + + cachedLoader := NewLoader(failingLoader) + + cfg := config{} + + err := xload.Load(context.Background(), &cfg, cachedLoader) + assert.Error(t, err) + assert.Equal(t, assert.AnError, err) +} + +func TestNewLoader_CacheError(t *testing.T) { + loader := xload.MapLoader(map[string]string{ + "KEY_1": "value-1", + "KEY_2": "value-2", + }) + + cfg := config{} + + t.Run("Cache SET error", func(t *testing.T) { + mc := NewMockCache(t) + + mc.On("Get", "KEY_1").Return("", nil) + mc.On("Set", "KEY_1", "value-1", mock.Anything).Return(assert.AnError) + + cachedLoader := NewLoader(loader, Cache(mc)) + + err := xload.Load(context.Background(), &cfg, cachedLoader) + assert.Error(t, err) + assert.Equal(t, assert.AnError, err) + + mc.AssertExpectations(t) + }) + + t.Run("Cache GET error", func(t *testing.T) { + mc := NewMockCache(t) + + mc.On("Get", "KEY_1").Return("", assert.AnError) + + cachedLoader := NewLoader(loader, Cache(mc)) + + err := xload.Load(context.Background(), &cfg, cachedLoader) + assert.Error(t, err) + assert.Equal(t, assert.AnError, err) + + mc.AssertExpectations(t) + }) +} diff --git a/xload/providers/cached/mock_test.go b/xload/providers/cached/mock_test.go new file mode 100644 index 0000000..259be46 --- /dev/null +++ b/xload/providers/cached/mock_test.go @@ -0,0 +1,67 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package cached + +import ( + time "time" + + mock "github.com/stretchr/testify/mock" +) + +// MockCache is an autogenerated mock type for the Cacher type +type MockCache struct { + mock.Mock +} + +// Get provides a mock function with given fields: key +func (_m *MockCache) Get(key string) (string, error) { + ret := _m.Called(key) + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(key) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(key) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Set provides a mock function with given fields: key, value, ttl +func (_m *MockCache) Set(key string, value string, ttl time.Duration) error { + ret := _m.Called(key, value, ttl) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, time.Duration) error); ok { + r0 = rf(key, value, ttl) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewMockCache interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockCache creates a new instance of MockCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockCache(t mockConstructorTestingTNewMockCache) *MockCache { + mock := &MockCache{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/xload/providers/cached/options.go b/xload/providers/cached/options.go new file mode 100644 index 0000000..d09c4e8 --- /dev/null +++ b/xload/providers/cached/options.go @@ -0,0 +1,55 @@ +package cached + +import "time" + +// Option configures a cached loader. +type Option interface { + apply(*options) +} + +type optionFunc func(*options) + +func (f optionFunc) apply(opts *options) { f(opts) } + +// TTL sets the TTL for the cached keys. +type TTL time.Duration + +func (t TTL) apply(o *options) { o.ttl = time.Duration(t) } + +// Cache sets the cache implementation for the loader. +func Cache(c Cacher) Option { + return optionFunc(func(o *options) { o.cache = c }) +} + +// DisableEmptyValueHit disables caching of empty values. +var DisableEmptyValueHit Option = emptyValHit(false) + +type emptyValHit bool + +func (e emptyValHit) apply(o *options) { o.emptyHit = bool(e) } + +type options struct { + ttl time.Duration + cache Cacher + emptyHit bool +} + +func defaultOptions() *options { + return &options{ + ttl: 5 * time.Minute, + emptyHit: true, + } +} + +func (o *options) apply(opts ...Option) { + for _, opt := range opts { + opt.apply(o) + } + + // DESIGN: If no cache is provided, use a simple map cache. + // This is after applying the options to avoid unnecessary + // allocations for the default cache. + if o.cache == nil { + o.cache = NewMapCache() + } +} diff --git a/xproto/pb_test.go b/xproto/pb_test.go index 73555e6..3f326fc 100644 --- a/xproto/pb_test.go +++ b/xproto/pb_test.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.28.1 -// protoc v3.21.12 +// protoc v4.23.4 // source: test.proto package xproto