From 812b36227b7cac28bfc7ad44106ceae5367b786c Mon Sep 17 00:00:00 2001 From: Garrett Ladley <92384606+garrettladley@users.noreply.github.com> Date: Tue, 11 Jun 2024 19:29:48 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20feat:=20db=20redis=20caching=20(?= =?UTF-8?q?#1007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/redis.go | 33 ++--- backend/config/settings.go | 8 ++ backend/constants/db.go | 1 + backend/database/cache/README.md | 3 + backend/database/cache/cache.go | 201 +++++++++++++++++++++++++++ backend/database/db.go | 10 ++ backend/database/store/storer.go | 29 +--- backend/database/store/stores.go | 4 +- backend/docker-compose.yml | 18 ++- backend/integrations/oauth/README.md | 2 +- config/.env.template | 10 +- go.work.sum | 3 + 12 files changed, 272 insertions(+), 50 deletions(-) create mode 100644 backend/database/cache/README.md create mode 100644 backend/database/cache/cache.go diff --git a/backend/config/redis.go b/backend/config/redis.go index e52abd69..dca53b61 100644 --- a/backend/config/redis.go +++ b/backend/config/redis.go @@ -2,6 +2,10 @@ package config import ( "fmt" + "runtime" + + "github.com/GenerateNU/sac/backend/constants" + "github.com/redis/go-redis/v9" m "github.com/garrettladley/mattress" ) @@ -15,24 +19,17 @@ type RedisSettings struct { // TLSConfig *TLSConfig } -func (r RedisSettings) Username() string { - return r.username -} - -func (r RedisSettings) Password() *m.Secret[string] { - return r.password -} - -func (r RedisSettings) Host() string { - return r.host -} - -func (r RedisSettings) Port() uint { - return r.port -} - -func (r RedisSettings) DB() int { - return r.db +func (r *RedisSettings) Into() *redis.Client { + return redis.NewClient(&redis.Options{ + Username: r.username, + Password: r.password.Expose(), + Addr: fmt.Sprintf("%s:%d", r.host, r.port), + DB: r.db, + PoolSize: 10 * runtime.GOMAXPROCS(0), + MaxActiveConns: constants.REDIS_MAX_OPEN_CONNECTIONS, + MaxIdleConns: constants.REDIS_MAX_IDLE_CONNECTIONS, + ContextTimeoutEnabled: true, + }) } type intermediateRedisSettings struct { diff --git a/backend/config/settings.go b/backend/config/settings.go index 32b3fafc..b257ba4e 100644 --- a/backend/config/settings.go +++ b/backend/config/settings.go @@ -3,6 +3,7 @@ package config type Settings struct { Application ApplicationSettings Database DatabaseSettings + DBCache RedisSettings Session SessionSettings RedisLimiter RedisSettings SuperUser SuperUserSettings @@ -21,6 +22,7 @@ type Integrations struct { type intermediateSettings struct { Application ApplicationSettings `envPrefix:"SAC_APPLICATION_"` Database intermediateDatabaseSettings `envPrefix:"SAC_DB_"` + DBCache intermediateRedisSettings `envPrefix:"SAC_REDIS_DB_CACHE_"` RedisSession intermediateRedisSettings `envPrefix:"SAC_REDIS_SESSION_"` Session intermediateSessionSettings `envPrefix:"SAC_SESSION_"` RedisLimiter intermediateRedisSettings `envPrefix:"SAC_REDIS_LIMITER_"` @@ -39,6 +41,11 @@ func (i *intermediateSettings) into() (*Settings, error) { return nil, err } + dbCache, err := i.DBCache.into() + if err != nil { + return nil, err + } + redisSession, err := i.RedisSession.into() if err != nil { return nil, err @@ -86,6 +93,7 @@ func (i *intermediateSettings) into() (*Settings, error) { return &Settings{ Application: i.Application, + DBCache: *dbCache, Database: *database, Session: *session, RedisLimiter: *redisLimiter, diff --git a/backend/constants/db.go b/backend/constants/db.go index eb6d046e..8fdd94d9 100644 --- a/backend/constants/db.go +++ b/backend/constants/db.go @@ -6,4 +6,5 @@ const ( MAX_IDLE_CONNECTIONS int = 10 MAX_OPEN_CONNECTIONS int = 100 DB_TIMEOUT time.Duration = 200 * time.Millisecond + DB_CACHE_TTL time.Duration = 60 * time.Second ) diff --git a/backend/database/cache/README.md b/backend/database/cache/README.md new file mode 100644 index 00000000..79009a7b --- /dev/null +++ b/backend/database/cache/README.md @@ -0,0 +1,3 @@ +# Cache Acknowledgement + +## Forked code from @evangwt's [grc package](https://github.com/evangwt/grc) to fit into our internal project structure diff --git a/backend/database/cache/cache.go b/backend/database/cache/cache.go new file mode 100644 index 00000000..f13b3d11 --- /dev/null +++ b/backend/database/cache/cache.go @@ -0,0 +1,201 @@ +package cache + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "log/slog" + "time" + + "github.com/GenerateNU/sac/backend/config" + go_json "github.com/goccy/go-json" + + "gorm.io/gorm/callbacks" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +var ( + useCacheKey struct{} + cacheTTLKey struct{} +) + +// GormCache is a cache plugin for gorm +type GormCache struct { + name string + client CacheClient + config CacheConfig +} + +// CacheClient is an interface for cache operations +type CacheClient interface { + Get(ctx context.Context, key string) (interface{}, error) + Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error +} + +// CacheConfig is a struct for cache options +type CacheConfig struct { + TTL time.Duration // cache expiration time + Prefix string // cache key prefix +} + +// NewGormCache returns a new GormCache instance +func NewGormCache(name string, client CacheClient, config CacheConfig) *GormCache { + return &GormCache{ + name: name, + client: client, + config: config, + } +} + +// Name returns the plugin name +func (g *GormCache) Name() string { + return g.name +} + +// Initialize initializes the plugin +func (g *GormCache) Initialize(db *gorm.DB) error { + return db.Callback().Query().Replace("gorm:query", g.queryCallback) +} + +// queryCallback is a callback function for query operations +func (g *GormCache) queryCallback(db *gorm.DB) { + if db.Error != nil { + return + } + + enableCache := g.enableCache(db) + + // build query sql + callbacks.BuildQuerySQL(db) + if db.DryRun || db.Error != nil { + return + } + + var ( + key string + err error + hit bool + ) + if enableCache { + key = g.cacheKey(db) + + // get value from cache + hit, err = g.loadCache(db, key) + if err != nil { + slog.Error("load cache failed", "error", err, "hit", hit) + return + } + + // hit cache + if hit { + return + } + } + + if !hit { + g.queryDB(db) + + if enableCache { + if err = g.setCache(db, key); err != nil { + slog.Error("set cache failed", "error", err) + } + } + } +} + +func (g *GormCache) enableCache(db *gorm.DB) bool { + ctx := db.Statement.Context + + // check if use cache + useCache, ok := ctx.Value(useCacheKey).(bool) + if !ok || !useCache { + return false // do not use cache, skip this callback + } + return true +} + +func (g *GormCache) cacheKey(db *gorm.DB) string { + sql := db.Dialector.Explain(db.Statement.SQL.String(), db.Statement.Vars...) + hash := sha256.Sum256([]byte(sql)) + key := g.config.Prefix + hex.EncodeToString(hash[:]) + return key +} + +func (g *GormCache) loadCache(db *gorm.DB, key string) (bool, error) { + value, err := g.client.Get(db.Statement.Context, key) + if err != nil && !errors.Is(err, redis.Nil) { + return false, err + } + + if value == nil { + return false, nil + } + + // cache hit, scan value to destination + if err = go_json.Unmarshal(value.([]byte), &db.Statement.Dest); err != nil { + return false, err + } + db.RowsAffected = int64(db.Statement.ReflectValue.Len()) + return true, nil +} + +func (g *GormCache) setCache(db *gorm.DB, key string) error { + ctx := db.Statement.Context + + // get cache ttl from context or config + ttl, ok := ctx.Value(cacheTTLKey).(time.Duration) + if !ok { + ttl = g.config.TTL // use default ttl + } + + // set value to cache with ttl + return g.client.Set(ctx, key, db.Statement.Dest, ttl) +} + +func (g *GormCache) queryDB(db *gorm.DB) { + rows, err := db.Statement.ConnPool.QueryContext(db.Statement.Context, db.Statement.SQL.String(), db.Statement.Vars...) + if err != nil { + if err := db.AddError(err); err != nil { + slog.Error("error encountered while adding error", "error", err) + } + return + } + defer func() { + if err := db.AddError(rows.Close()); err != nil { + slog.Error("error encountered while closing rows", "error", err) + } + }() + gorm.Scan(rows, db, 0) +} + +type RedisClient struct { + client *redis.Client +} + +// NewRedisClient returns a new RedisClient instance +func NewRedisClient(settings config.RedisSettings) *RedisClient { + return &RedisClient{ + client: settings.Into(), + } +} + +// Get gets value from redis by key using json encoding/decoding +func (r *RedisClient) Get(ctx context.Context, key string) (interface{}, error) { + data, err := r.client.Get(ctx, key).Bytes() + if err != nil { + return nil, err + } + return data, nil +} + +// Set sets value to redis by key with ttl using json encoding/decoding +func (r *RedisClient) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { + data, err := go_json.Marshal(value) // encode value to json bytes using json encoding/decoding + if err != nil { + return err + } + return r.client.Set(ctx, key, data, ttl).Err() +} diff --git a/backend/database/db.go b/backend/database/db.go index 928dd1b0..e09aeb8a 100644 --- a/backend/database/db.go +++ b/backend/database/db.go @@ -5,6 +5,7 @@ import ( "github.com/GenerateNU/sac/backend/config" "github.com/GenerateNU/sac/backend/constants" + "github.com/GenerateNU/sac/backend/database/cache" "github.com/GenerateNU/sac/backend/entities/models" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -17,6 +18,15 @@ func ConfigureDB(settings config.Settings) (*gorm.DB, error) { return nil, err } + cache := cache.NewGormCache("db_cache", cache.NewRedisClient(settings.DBCache), cache.CacheConfig{ + TTL: constants.DB_CACHE_TTL, + Prefix: "cache:", + }) + + if err := db.Use(cache); err != nil { + return nil, err + } + if err := CreateSuperUserIfNotExists(settings.SuperUser, db); err != nil { return nil, err } diff --git a/backend/database/store/storer.go b/backend/database/store/storer.go index 00978c1d..e16dd116 100644 --- a/backend/database/store/storer.go +++ b/backend/database/store/storer.go @@ -2,15 +2,11 @@ package store import ( "context" - "fmt" "log/slog" - "runtime" "time" - "github.com/GenerateNU/sac/backend/constants" + "github.com/GenerateNU/sac/backend/config" "github.com/redis/go-redis/v9" - - m "github.com/garrettladley/mattress" ) // an implementation of https://github.com/gofiber/storage @@ -26,28 +22,9 @@ type RedisClient struct { client *redis.Client } -type RedisSettings interface { - Username() string - Password() *m.Secret[string] - Host() string - Port() uint - DB() int -} - -func NewRedisClient(settings RedisSettings) *RedisClient { - client := redis.NewClient(&redis.Options{ - Username: settings.Username(), - Password: settings.Password().Expose(), - Addr: fmt.Sprintf("%s:%d", settings.Host(), settings.Port()), - DB: settings.DB(), - PoolSize: 10 * runtime.GOMAXPROCS(0), - MaxActiveConns: constants.REDIS_MAX_OPEN_CONNECTIONS, - MaxIdleConns: constants.REDIS_MAX_IDLE_CONNECTIONS, - ContextTimeoutEnabled: true, - }) - +func NewRedisClient(settings config.RedisSettings) *RedisClient { return &RedisClient{ - client: client, + client: settings.Into(), } } diff --git a/backend/database/store/stores.go b/backend/database/store/stores.go index b16a68f6..e439df4c 100644 --- a/backend/database/store/stores.go +++ b/backend/database/store/stores.go @@ -1,10 +1,12 @@ package store +import "github.com/GenerateNU/sac/backend/config" + type Stores struct { Limiter Storer } -func ConfigureStores(limiter RedisSettings) *Stores { +func ConfigureStores(limiter config.RedisSettings) *Stores { return &Stores{ Limiter: NewRedisClient(limiter), } diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 20ee008d..b39f27a3 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,11 +1,24 @@ services: + redis-db-cache: + build: + context: . + dockerfile: Dockerfile.redis + container_name: redis_db_cache + ports: + - 6380:6379 + environment: + - REDIS_USERNAME=redis_db_cache + - REDIS_PASSWORD=redis_db_cache!#1 + - REDIS_DISABLE_DEFAULT_USER="true" + volumes: + - redis-db-cache-data:/data redis-session: build: context: . dockerfile: Dockerfile.redis container_name: redis_session ports: - - 6380:6379 + - 6381:6379 environment: - REDIS_USERNAME=redis_session - REDIS_PASSWORD=redis_session!#1 @@ -18,7 +31,7 @@ services: dockerfile: Dockerfile.redis container_name: redis_limiter ports: - - 6381:6379 + - 6382:6379 environment: - REDIS_USERNAME=redis_limiter - REDIS_PASSWORD=redis_limiter!#1 @@ -64,6 +77,7 @@ services: - opensearch-net volumes: + redis-db-cache-data: redis-session-data: redis-limiter-data: opensearch-data1: diff --git a/backend/integrations/oauth/README.md b/backend/integrations/oauth/README.md index 99a15559..0d24310e 100644 --- a/backend/integrations/oauth/README.md +++ b/backend/integrations/oauth/README.md @@ -1,3 +1,3 @@ # OAuth Acknowledgement -## Forked code from @markbates' [goth package](https://github.com/markbates/goth/tree/v1.80.0) and @Shareed2k's [goth_fiber package](https://github.com/Shareed2k/goth_fiber/tree/master) to fit into our internal structure +## Forked code from @markbates' [goth package](https://github.com/markbates/goth/tree/v1.80.0) and @Shareed2k's [goth_fiber package](https://github.com/Shareed2k/goth_fiber/tree/master) to fit into our internal project structure diff --git a/config/.env.template b/config/.env.template index e9629296..03def4f2 100644 --- a/config/.env.template +++ b/config/.env.template @@ -9,10 +9,16 @@ SAC_DB_HOST="127.0.0.1" SAC_DB_NAME="sac" SAC_DB_REQUIRE_SSL="false" +SAC_REDIS_DB_CACHE_USERNAME="redis_db_cache" +SAC_REDIS_DB_CACHE_PASSWORD="redis_db_cache!#1" +SAC_REDIS_DB_CACHE_HOST="127.0.0.1" +SAC_REDIS_DB_CACHE_PORT="6380" +SAC_REDIS_DB_CACHE_DB="0" + SAC_REDIS_SESSION_USERNAME="redis_session" SAC_REDIS_SESSION_PASSWORD="redis_session!#1" SAC_REDIS_SESSION_HOST="127.0.0.1" -SAC_REDIS_SESSION_PORT="6380" +SAC_REDIS_SESSION_PORT="6381" SAC_REDIS_SESSION_DB="0" SAC_SESSION_PASSPHRASE="muneer tyler garrett brian alder sac" @@ -20,7 +26,7 @@ SAC_SESSION_PASSPHRASE="muneer tyler garrett brian alder sac" SAC_REDIS_LIMITER_USERNAME="redis_limiter" SAC_REDIS_LIMITER_PASSWORD="redis_limiter!#1" SAC_REDIS_LIMITER_HOST="127.0.0.1" -SAC_REDIS_LIMITER_PORT="6381" +SAC_REDIS_LIMITER_PORT="6382" SAC_REDIS_LIMITER_DB="0" SAC_AWS_BUCKET_NAME="SAC_AWS_BUCKET_NAME" diff --git a/go.work.sum b/go.work.sum index 16a0105e..33d1a2f1 100644 --- a/go.work.sum +++ b/go.work.sum @@ -420,10 +420,13 @@ github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uY github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba h1:fhFP5RliM2HW/8XdcO5QngSfFli9GcRIpMXvypTQt6E= github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1:ncO5VaFWh0Nrt+4KT4mOZboaczBZcLuHrG+/sUeP8gI= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU= github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pierrec/lz4/v4 v4.1.16 h1:kQPfno+wyx6C5572ABwV+Uo3pDFzQ7yhyGchSyRda0c= github.com/pierrec/lz4/v4 v4.1.16/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=