wip: feat: add redis cache

Ayman Bagabas created

Change summary

go.mod                       |  2 
go.sum                       |  6 ++
internal/init/cache.go       |  2 
server/cache/redis/config.go | 47 ++++++++++++++++++++
server/cache/redis/redis.go  | 88 ++++++++++++++++++++++++++++++++++++++
5 files changed, 145 insertions(+)

Detailed changes

go.mod 🔗

@@ -30,6 +30,7 @@ require (
 	github.com/muesli/mango-cobra v1.2.0
 	github.com/muesli/roff v0.1.0
 	github.com/prometheus/client_golang v1.15.1
+	github.com/redis/go-redis/v9 v9.0.4
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/spf13/cobra v1.7.0
 	go.uber.org/automaxprocs v1.5.2
@@ -49,6 +50,7 @@ require (
 	github.com/caarlos0/sshmarshal v0.1.0 // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
+	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/dlclark/regexp2 v1.4.0 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/golang/protobuf v1.5.3 // indirect

go.sum 🔗

@@ -15,6 +15,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
+github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
 github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0=
 github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo=
@@ -50,6 +52,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -170,6 +174,8 @@ github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI
 github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
 github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
 github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
+github.com/redis/go-redis/v9 v9.0.4 h1:FC82T+CHJ/Q/PdyLW++GeCO+Ol59Y4T7R4jbgjvktgc=
+github.com/redis/go-redis/v9 v9.0.4/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=

internal/init/cache.go 🔗

@@ -4,9 +4,11 @@ import (
 	"github.com/charmbracelet/soft-serve/server/cache"
 	"github.com/charmbracelet/soft-serve/server/cache/lru"
 	"github.com/charmbracelet/soft-serve/server/cache/noop"
+	"github.com/charmbracelet/soft-serve/server/cache/redis"
 )
 
 func init() {
 	cache.Register("lru", lru.NewCache)
 	cache.Register("noop", noop.NewCache)
+	cache.Register("redis", redis.NewCache)
 }

server/cache/redis/config.go 🔗

@@ -0,0 +1,47 @@
+package redis
+
+import (
+	"github.com/charmbracelet/soft-serve/server/config"
+)
+
+// Config is the configuration for the Redis cache.
+type Config struct {
+	// Addr is the Redis address [host][:port].
+	Addr string `env:"ADDR" yaml:"addr"`
+	// Username is the Redis username.
+	Username string `env:"USERNAME" yaml:"username"`
+	// Password is the Redis password.
+	Password string `env:"PASSWORD" yaml:"password"`
+	// DB is the Redis database.
+	DB int `env:"DB" yaml:"db"`
+}
+
+// NewConfig returns a new Redis cache configuration.
+// If path is empty, the default config path will be used.
+//
+// TODO: add support for TLS and other Redis options.
+func NewConfig(path string) (*Config, error) {
+	if path == "" {
+		path = config.DefaultConfig().FilePath()
+	}
+
+	cfg := DefaultConfig()
+	wrapper := &struct {
+		Redis *Config `envPrefix:"REDIS_" yaml:"redis"`
+	}{
+		Redis: cfg,
+	}
+
+	if err := config.ParseConfig(wrapper, path); err != nil {
+		return cfg, err
+	}
+
+	return cfg, nil
+}
+
+// DefaultConfig returns the default configuration for the Redis cache.
+func DefaultConfig() *Config {
+	return &Config{
+		Addr: "localhost:6379",
+	}
+}

server/cache/redis/redis.go 🔗

@@ -0,0 +1,88 @@
+package redis
+
+import (
+	"context"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/cache"
+	"github.com/redis/go-redis/v9"
+)
+
+// Cache is a Redis cache.
+type Cache struct {
+	client *redis.Client
+}
+
+// NewCache returns a new Redis cache.
+// It converts non-string types to JSON before storing/retrieving them.
+func NewCache(ctx context.Context, _ ...cache.Option) (cache.Cache, error) {
+	cfg, err := NewConfig("")
+	if err != nil {
+		return nil, err
+	}
+
+	client := redis.NewClient(&redis.Options{
+		Addr:     cfg.Addr,
+		Username: cfg.Username,
+		Password: cfg.Password,
+		DB:       cfg.DB,
+	})
+
+	return &Cache{
+		client: client,
+	}, client.Ping(ctx).Err()
+}
+
+type option struct {
+	cache.Item
+	ttl time.Duration
+}
+
+func (*option) item() {}
+
+// WithTTL sets the TTL for the cache item.
+func WithTTL(ttl time.Duration) cache.ItemOption {
+	return func(io cache.Item) {
+		i := io.(*option)
+		i.ttl = ttl
+	}
+}
+
+// Contains implements cache.Cache.
+func (r *Cache) Contains(ctx context.Context, key string) bool {
+	return r.client.Exists(ctx, key).Val() == 1
+}
+
+// Delete implements cache.Cache.
+func (r *Cache) Delete(ctx context.Context, key string) {
+	r.client.Del(ctx, key)
+}
+
+// Get implements cache.Cache.
+func (r *Cache) Get(ctx context.Context, key string) (value any, ok bool) {
+	val := r.client.Get(ctx, key)
+	if val.Err() != nil {
+		return nil, false
+	}
+
+	return val.Val(), true
+}
+
+// Keys implements cache.Cache.
+func (r *Cache) Keys(ctx context.Context) []string {
+	return r.client.Keys(ctx, "*").Val()
+}
+
+// Len implements cache.Cache.
+func (r *Cache) Len(ctx context.Context) int64 {
+	return r.client.DBSize(ctx).Val()
+}
+
+// Set implements cache.Cache.
+func (r *Cache) Set(ctx context.Context, key string, val any, opts ...cache.ItemOption) {
+	var opt option
+	for _, o := range opts {
+		o(&opt)
+	}
+	r.client.Set(ctx, key, val, opt.ttl)
+}