Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ func main() {
}

defer func() {
if err := app.Repo.Close(); err != nil {
panic(fmt.Sprintf("failed to close repo: %v", err))
if err := app.Close(); err != nil {
panic(fmt.Sprintf("failed to close app resources: %v", err))
}
}()

Expand Down
1 change: 1 addition & 0 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ type Config struct {
S3 `env:",prefix=AWS_S3_"`
LLM `env:",prefix=LLM_"`
Clerk `env:",prefix=CLERK_"`
Redis `env:",prefix=REDIS_"`
OpenSearch `env:",prefix=OPENSEARCH_"`
}
6 changes: 6 additions & 0 deletions backend/config/redis.go
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"`
}
19 changes: 19 additions & 0 deletions backend/config/redis_test.go
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)
}
Copy link
Copy Markdown
Contributor

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? 🤔

98 changes: 98 additions & 0 deletions backend/internal/cache/json_cache.go
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")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

117 changes: 117 additions & 0 deletions backend/internal/cache/json_cache_test.go
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)
}
Loading
Loading