Skip to content
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

📝 feat: db redis caching #1007

Merged
merged 5 commits into from
Jun 11, 2024
Merged
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
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
Loading