-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add redis caching, ttl, on initial endpoints #280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
28c3e64
b4fa8d3
b13f4f0
6846776
32ef258
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package config | ||
|
|
||
| type Redis struct { | ||
| Addr string `env:"ADDR, default=localhost:6379"` | ||
| Password string `env:"PASSWORD"` | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/sethvargo/go-envconfig" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestRedisConfigDefaults(t *testing.T) { | ||
| var cfg Redis | ||
| err := envconfig.Process(context.Background(), &cfg) | ||
|
|
||
| require.NoError(t, err) | ||
| assert.Equal(t, "localhost:6379", cfg.Addr) | ||
| assert.Equal(t, "", cfg.Password) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| package cache | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "log/slog" | ||
| "time" | ||
|
|
||
| goredis "github.com/redis/go-redis/v9" | ||
| ) | ||
|
|
||
| var ErrCacheMiss = errors.New("cache miss") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mv this to our err package |
||
|
|
||
| type KVStore interface { | ||
| Get(ctx context.Context, key string) (string, error) | ||
| Set(ctx context.Context, key string, value string, ttl time.Duration) error | ||
| } | ||
|
|
||
| type JSONCache struct { | ||
| store KVStore | ||
| logger *slog.Logger | ||
| } | ||
|
|
||
| type RedisStore struct { | ||
| client *goredis.Client | ||
| } | ||
|
|
||
| func NewJSONCache(store KVStore, logger ...*slog.Logger) *JSONCache { | ||
| if store == nil { | ||
| return nil | ||
| } | ||
|
|
||
| resolvedLogger := slog.Default() | ||
| if len(logger) > 0 && logger[0] != nil { | ||
| resolvedLogger = logger[0] | ||
| } | ||
|
|
||
| return &JSONCache{store: store, logger: resolvedLogger} | ||
| } | ||
|
|
||
| func NewRedisStore(client *goredis.Client) *RedisStore { | ||
| if client == nil { | ||
| return nil | ||
| } | ||
| return &RedisStore{client: client} | ||
| } | ||
|
|
||
| func (s *RedisStore) Get(ctx context.Context, key string) (string, error) { | ||
| value, err := s.client.Get(ctx, key).Result() | ||
| if errors.Is(err, goredis.Nil) { | ||
| return "", ErrCacheMiss | ||
| } | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return value, nil | ||
| } | ||
|
|
||
| func (s *RedisStore) Set(ctx context.Context, key string, value string, ttl time.Duration) error { | ||
| return s.client.Set(ctx, key, value, ttl).Err() | ||
| } | ||
|
|
||
| func (c *JSONCache) GetJSON(ctx context.Context, key string, dest any) (bool, error) { | ||
| value, err := c.store.Get(ctx, key) | ||
| if errors.Is(err, ErrCacheMiss) { | ||
| return false, nil | ||
| } | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
| if err := json.Unmarshal([]byte(value), dest); err != nil { | ||
| return false, err | ||
| } | ||
| return true, nil | ||
| } | ||
|
|
||
| func (c *JSONCache) SetJSON(ctx context.Context, key string, value any, ttl time.Duration) error { | ||
| encoded, err := json.Marshal(value) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| return c.store.Set(ctx, key, string(encoded), ttl) | ||
| } | ||
|
|
||
| func (c *JSONCache) WarnReadError(key string, err error) { | ||
| if c == nil || err == nil { | ||
| return | ||
| } | ||
| c.logger.Warn("redis cache read failed", "key", key, "err", err) | ||
| } | ||
|
|
||
| func (c *JSONCache) WarnWriteError(key string, err error) { | ||
| if c == nil || err == nil { | ||
| return | ||
| } | ||
| c.logger.Warn("redis cache write failed", "key", key, "err", err) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks good but feels like this should be separated into two different layers. One for the abstracted cache layer and the other for the Redis access itself. Caller for our KV store shouldn't need to know how our Redis access works under the hood, also would just be good abstraction for future caching impl if we want to expand on it |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| package cache | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| type fakeKVStore struct { | ||
| values map[string]string | ||
| getErr error | ||
| setErr error | ||
| lastSetKey string | ||
| lastSetValue string | ||
| lastSetTTL time.Duration | ||
| } | ||
|
|
||
| func (s *fakeKVStore) Get(_ context.Context, key string) (string, error) { | ||
| if s.getErr != nil { | ||
| return "", s.getErr | ||
| } | ||
| value, ok := s.values[key] | ||
| if !ok { | ||
| return "", ErrCacheMiss | ||
| } | ||
| return value, nil | ||
| } | ||
|
|
||
| func (s *fakeKVStore) Set(_ context.Context, key string, value string, ttl time.Duration) error { | ||
| if s.setErr != nil { | ||
| return s.setErr | ||
| } | ||
| if s.values == nil { | ||
| s.values = map[string]string{} | ||
| } | ||
| s.values[key] = value | ||
| s.lastSetKey = key | ||
| s.lastSetValue = value | ||
| s.lastSetTTL = ttl | ||
| return nil | ||
| } | ||
|
|
||
| func TestJSONCache_GetJSONReturnsCachedValue(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| store := &fakeKVStore{ | ||
| values: map[string]string{ | ||
| "user:1": `{"name":"Ada","count":3}`, | ||
| }, | ||
| } | ||
| cache := NewJSONCache(store) | ||
|
|
||
| var dest struct { | ||
| Name string `json:"name"` | ||
| Count int `json:"count"` | ||
| } | ||
| hit, err := cache.GetJSON(context.Background(), "user:1", &dest) | ||
|
|
||
| require.NoError(t, err) | ||
| assert.True(t, hit) | ||
| assert.Equal(t, "Ada", dest.Name) | ||
| assert.Equal(t, 3, dest.Count) | ||
| } | ||
|
|
||
| func TestJSONCache_GetJSONReturnsMissForMissingKey(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| cache := NewJSONCache(&fakeKVStore{}) | ||
|
|
||
| var dest struct { | ||
| Name string `json:"name"` | ||
| } | ||
| hit, err := cache.GetJSON(context.Background(), "missing", &dest) | ||
|
|
||
| require.NoError(t, err) | ||
| assert.False(t, hit) | ||
| assert.Equal(t, "", dest.Name) | ||
| } | ||
|
|
||
| func TestJSONCache_SetJSONStoresSerializedValue(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| store := &fakeKVStore{} | ||
| cache := NewJSONCache(store) | ||
|
|
||
| value := struct { | ||
| Name string `json:"name"` | ||
| Count int `json:"count"` | ||
| }{ | ||
| Name: "Ada", | ||
| Count: 3, | ||
| } | ||
|
|
||
| err := cache.SetJSON(context.Background(), "user:1", value, 2*time.Minute) | ||
|
|
||
| require.NoError(t, err) | ||
| assert.Equal(t, "user:1", store.lastSetKey) | ||
| assert.JSONEq(t, `{"name":"Ada","count":3}`, store.lastSetValue) | ||
| assert.Equal(t, 2*time.Minute, store.lastSetTTL) | ||
| } | ||
|
|
||
| func TestJSONCache_GetJSONReturnsStoreError(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| expectedErr := errors.New("boom") | ||
| cache := NewJSONCache(&fakeKVStore{getErr: expectedErr}) | ||
|
|
||
| var dest struct{} | ||
| hit, err := cache.GetJSON(context.Background(), "user:1", &dest) | ||
|
|
||
| assert.False(t, hit) | ||
| require.ErrorIs(t, err, expectedErr) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would this test look in a prod container? 🤔