Skip to content

Commit

Permalink
📝 feat: db redis caching (#1007)
Browse files Browse the repository at this point in the history
  • Loading branch information
garrettladley authored Jun 11, 2024
1 parent ccb0a3c commit 812b362
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 50 deletions.
33 changes: 15 additions & 18 deletions backend/config/redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions backend/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
type Settings struct {
Application ApplicationSettings
Database DatabaseSettings
DBCache RedisSettings
Session SessionSettings
RedisLimiter RedisSettings
SuperUser SuperUserSettings
Expand All @@ -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_"`
Expand All @@ -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
Expand Down Expand Up @@ -86,6 +93,7 @@ func (i *intermediateSettings) into() (*Settings, error) {

return &Settings{
Application: i.Application,
DBCache: *dbCache,
Database: *database,
Session: *session,
RedisLimiter: *redisLimiter,
Expand Down
1 change: 1 addition & 0 deletions backend/constants/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
3 changes: 3 additions & 0 deletions backend/database/cache/README.md
Original file line number Diff line number Diff line change
@@ -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
201 changes: 201 additions & 0 deletions backend/database/cache/cache.go
Original file line number Diff line number Diff line change
@@ -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()
}
10 changes: 10 additions & 0 deletions backend/database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
Expand Down
29 changes: 3 additions & 26 deletions backend/database/store/storer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
}
}

Expand Down
4 changes: 3 additions & 1 deletion backend/database/store/stores.go
Original file line number Diff line number Diff line change
@@ -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),
}
Expand Down
Loading

0 comments on commit 812b362

Please sign in to comment.