From 12e02949452d3987fd245d53506fd494006adc22 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 11 Jun 2024 09:46:38 +0100 Subject: [PATCH] Add distibuted cache with Redis --- README.md | 10 +- api.go | 16 +++- cache.go | 74 ++++++++++++++- cache/{ => memory}/cache.go | 49 ++++------ cache/{ => memory}/cache_bench_test.go | 0 cache/{ => memory}/cache_test.go | 33 ------- cache/redis/connection.go | 73 +++++++++++++++ cache/redis/connection_test.go | 123 +++++++++++++++++++++++++ cache_test.go | 57 +++++++++++- go.mod | 3 + go.sum | 10 ++ main.go | 6 ++ main_test.go | 6 ++ server.go | 25 +++-- struct_config.go | 11 ++- 15 files changed, 407 insertions(+), 89 deletions(-) rename cache/{ => memory}/cache.go (87%) rename cache/{ => memory}/cache_bench_test.go (100%) rename cache/{ => memory}/cache_test.go (73%) create mode 100644 cache/redis/connection.go create mode 100644 cache/redis/connection_test.go diff --git a/README.md b/README.md index 9f69e63..3dd3ec9 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ You can still use the non-prefixed environment variables in the spirit of the ba | `ROLE_RATE_LIMIT` | Enable request rate limiting based on role| `false` | | `ENABLE_GLOBAL_CACHE` | Enable the cache | `false` | | `CACHE_TTL` | The cache TTL | `60` | +| `ENABLE_REDIS_CACHE` | Enable distributed Redis cache | `false` | +| `CACHE_REDIS_URL` | URL to redis server / cluster endpoint | `localhost:6379` | +| `CACHE_REDIS_PASSWORD` | Redis connection password | `` | +| `CACHE_REDIS_DB` | Redis DB id | `0` | | `LOG_LEVEL` | The log level | `info` | | `BLOCK_SCHEMA_INTROSPECTION`| Blocks the schema introspection | `false` | | `ALLOWED_INTROSPECTION` | Allow only certain queries in introspection | `` | @@ -140,7 +144,7 @@ You can still use the non-prefixed environment variables in the spirit of the ba #### Caching The cache engine is enabled in the background by default, using no additional resources. -You can then start using the cache by setting the `ENABLE_GLOBAL_CACHE` environment variable to `true` - which will enable the cache for all queries without introspection. You can leave the global cache disabled and enable the cache for specific queries by adding the `@cached` directive to the query. +You can then start using the cache by setting the `ENABLE_GLOBAL_CACHE` or `ENABLE_REDIS_CACHE` environment variable to `true` - which will enable the cache for all queries without introspection. You can leave the global cache disabled and enable the cache for specific queries by adding the `@cached` directive to the query. In the case of the `@cached` you can add additional parameters to the directive which will set the cache for specific queries to the provided time. For example, `query MyCachedQuery @cached(ttl: 90) ....` will set the cache for the query to 90 seconds. @@ -159,6 +163,7 @@ query MyProducts @cached(refresh: true) { ``` Since version `0.5.30` the cache is gzipped in the memory, which should optimise the memory usage quite significantly. +Since version `0.15.48` the you can also use the distributed Redis cache. #### Read-only endpoint @@ -289,4 +294,7 @@ graphql_proxy_executed_query{user_id="-",op_type="query",op_name="checkIfSpamAIR graphql_proxy_requests_failed 324 graphql_proxy_requests_skipped 0 graphql_proxy_requests_succesful 454823 +graphql_proxy_cache_hit{microservice="graphql_proxy",pod="hasura-w-proxy-internal-6b5f4b4bbb-9xwfc"} 7 +graphql_proxy_cache_hit{pod="hasura-w-proxy-internal-6b5f4b4bbb-9xwfc",microservice="graphql_proxy"} 1 +graphql_proxy_cache_miss{microservice="graphql_proxy",pod="hasura-w-proxy-internal-6b5f4b4bbb-9xwfc"} 23 ``` diff --git a/api.go b/api.go index 4dadf04..8514b78 100644 --- a/api.go +++ b/api.go @@ -54,16 +54,20 @@ func checkIfUserIsBanned(c *fiber.Ctx, userID string) bool { func apiClearCache(c *fiber.Ctx) error { cfg.Logger.Debug("Clearing cache via API", nil) - cfg.Cache.CacheClient.ClearCache() + cacheClear() cfg.Logger.Info("Cache cleared via API", nil) c.Status(200).SendString("OK: cache cleared") return nil } func apiCacheStats(c *fiber.Ctx) error { - stats := cfg.Cache.CacheClient.ShowStats() + stats := getCacheStats() cfg.Logger.Debug("Getting cache stats via API", map[string]interface{}{"stats": stats}) - c.JSON(stats) + err := c.JSON(stats) + if err != nil { + cfg.Logger.Error("Can't marshal cache stats", map[string]interface{}{"error": err.Error()}) + return err + } return nil } @@ -128,6 +132,12 @@ func loadBannedUsers() { cfg.Logger.Error("Can't create the file", map[string]interface{}{"error": err.Error()}) return } + // write empty json to the file + err = os.WriteFile(cfg.Api.BannedUsersFile, []byte("{}"), 0644) + if err != nil { + cfg.Logger.Error("Can't write to the file", map[string]interface{}{"error": err.Error()}) + return + } } fileLock := flock.New(fmt.Sprintf("%s.lock", cfg.Api.BannedUsersFile)) diff --git a/cache.go b/cache.go index c72a61b..e25fb1e 100644 --- a/cache.go +++ b/cache.go @@ -5,7 +5,26 @@ import ( fiber "github.com/gofiber/fiber/v2" "github.com/gookit/goutil/strutil" - libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache" + libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/memory" + libpack_redis "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/redis" +) + +type CacheStats struct { + CachedQueries int `json:"cached_queries"` + CacheHits int `json:"cache_hits"` + CacheMisses int `json:"cache_misses"` +} + +type CacheClient interface { + Set(key string, value []byte, ttl time.Duration) + Get(key string) ([]byte, bool) + Delete(key string) + Clear() + CountQueries() int +} + +var ( + cacheStats *CacheStats ) func calculateHash(c *fiber.Ctx) string { @@ -13,17 +32,64 @@ func calculateHash(c *fiber.Ctx) string { } func enableCache() { - cfg.Cache.CacheClient = libpack_cache.New(time.Duration(cfg.Cache.CacheTTL) * time.Second) + cacheStats = &CacheStats{} + if shouldUseRedisCache() { + cfg.Logger.Info("Using Redis cache", nil) + cfg.Cache.Client = libpack_redis.NewClient(&libpack_redis.RedisClientConfig{ + RedisDB: cfg.Cache.CacheRedisDB, + RedisServer: cfg.Cache.CacheRedisURL, + RedisPassword: cfg.Cache.CacheRedisPassword, + }) + } else { + cfg.Logger.Info("Using in-memory cache", nil) + cfg.Cache.Client = libpack_cache.New(time.Duration(cfg.Cache.CacheTTL) * time.Second) + } } func cacheLookup(hash string) []byte { - obj, found := cfg.Cache.CacheClient.Get(hash) + obj, found := cfg.Cache.Client.Get(hash) if found { + cacheStats.CacheHits++ return obj } + cacheStats.CacheMisses++ return nil } func cacheDelete(hash string) { - cfg.Cache.CacheClient.Delete(hash) + cfg.Logger.Debug("Deleting data from cache", map[string]interface{}{"hash": hash}) + cacheStats.CachedQueries-- + cfg.Cache.Client.Delete(hash) +} + +func cacheStore(hash string, data []byte) { + cfg.Logger.Debug("Storing data in cache", map[string]interface{}{"hash": hash}) + cacheStats.CachedQueries++ + cfg.Cache.Client.Set(hash, data, time.Duration(cfg.Cache.CacheTTL)*time.Second) +} + +func cacheStoreWithTTL(hash string, data []byte, ttl time.Duration) { + cfg.Logger.Debug("Storing data in cache with TTL", map[string]interface{}{"hash": hash, "ttl": ttl}) + cacheStats.CachedQueries++ + cfg.Cache.Client.Set(hash, data, ttl) +} + +func cacheGetQueries() int { + cfg.Logger.Debug("Counting cache queries", nil) + return cfg.Cache.Client.CountQueries() +} + +func cacheClear() { + cfg.Cache.Client.Clear() + cacheStats = &CacheStats{} +} + +func getCacheStats() *CacheStats { + cfg.Logger.Debug("Getting cache stats", nil) + cacheStats.CachedQueries = cacheGetQueries() + return cacheStats +} + +func shouldUseRedisCache() bool { + return cfg.Cache.CacheRedisEnable } diff --git a/cache/cache.go b/cache/memory/cache.go similarity index 87% rename from cache/cache.go rename to cache/memory/cache.go index 404e5e9..4def49f 100644 --- a/cache/cache.go +++ b/cache/memory/cache.go @@ -18,8 +18,6 @@ type Cache struct { globalTTL time.Duration compressPool sync.Pool decompressPool sync.Pool - cacheHits int - cacheMisses int sync.RWMutex // Reintroduced to provide lock methods } @@ -53,6 +51,7 @@ func (c *Cache) cleanupRoutine(globalTTL time.Duration) { c.CleanExpiredEntries() } } + func (c *Cache) Set(key string, value []byte, ttl time.Duration) { c.Lock() // use the lock defer c.Unlock() @@ -77,16 +76,13 @@ func (c *Cache) Get(key string) ([]byte, bool) { entry, ok := c.entries.Load(key) if !ok || entry.(CacheEntry).ExpiresAt.Before(time.Now()) { - c.cacheMisses++ return nil, false } compressedValue := entry.(CacheEntry).Value value, err := c.decompress(compressedValue) if err != nil { - c.cacheMisses++ return nil, false } - c.cacheHits++ return value, true } @@ -102,24 +98,11 @@ func (c *Cache) Delete(key string) { c.entries.Delete(key) } -func (c *Cache) CleanExpiredEntries() { - now := time.Now() - c.entries.Range(func(key, value interface{}) bool { - entry := value.(CacheEntry) - if entry.ExpiresAt.Before(now) { - c.entries.Delete(key) - } - return true - }) +func (c *Cache) Clear() { + c.entries = sync.Map{} } -type CacheStats struct { - CachedQueries int `json:"cached_queries"` - CacheHits int `json:"cache_hits"` - CacheMisses int `json:"cache_misses"` -} - -func (c *Cache) ShowStats() CacheStats { +func (c *Cache) CountQueries() int { c.RLock() defer c.RUnlock() var count int @@ -127,18 +110,7 @@ func (c *Cache) ShowStats() CacheStats { count++ return true }) - cs := CacheStats{ - CachedQueries: count, - CacheHits: c.cacheHits, - CacheMisses: c.cacheMisses, - } - return cs -} - -func (c *Cache) ClearCache() { - c.cacheHits = 0 - c.cacheMisses = 0 - c.entries = sync.Map{} + return count } func (c *Cache) compress(data []byte) ([]byte, error) { @@ -183,3 +155,14 @@ func (c *Cache) decompress(data []byte) ([]byte, error) { } return decompressedData, nil } + +func (c *Cache) CleanExpiredEntries() { + now := time.Now() + c.entries.Range(func(key, value interface{}) bool { + entry := value.(CacheEntry) + if entry.ExpiresAt.Before(now) { + c.entries.Delete(key) + } + return true + }) +} diff --git a/cache/cache_bench_test.go b/cache/memory/cache_bench_test.go similarity index 100% rename from cache/cache_bench_test.go rename to cache/memory/cache_bench_test.go diff --git a/cache/cache_test.go b/cache/memory/cache_test.go similarity index 73% rename from cache/cache_test.go rename to cache/memory/cache_test.go index 8714742..b0e2cc7 100644 --- a/cache/cache_test.go +++ b/cache/memory/cache_test.go @@ -110,36 +110,3 @@ func (suite *CacheTestSuite) Test_CacheExpire() { }) } } - -func (suite *CacheTestSuite) Test_CacheStats() { - cache := New(30 * time.Second) - tests := []struct { - name string - cache_value string - ttl time.Duration - }{ - { - name: "test1", - cache_value: "test1-123", - ttl: 2 * time.Second, - }, - { - name: "test2", - cache_value: "test2-123", - ttl: 5 * time.Second, - }, - } - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - cache.Set(tt.name, []byte(tt.name), tt.ttl) - c, ok := cache.Get(tt.name) - suite.Equal(true, ok) - suite.Equal(tt.name, string(c)) - }) - } - cache.Get("non-existent-non-cached-key") - stats := cache.ShowStats() - suite.Equal(2, stats.CacheHits, "CacheHits") - suite.Equal(1, stats.CacheMisses, "CacheMisses") - suite.Equal(2, stats.CachedQueries, "CachedQueries") -} diff --git a/cache/redis/connection.go b/cache/redis/connection.go new file mode 100644 index 0000000..1174184 --- /dev/null +++ b/cache/redis/connection.go @@ -0,0 +1,73 @@ +package libpack_redis + +import ( + "context" + "time" + + redis "github.com/redis/go-redis/v9" +) + +var () + +type RedisConfig struct { + client *redis.Client + ctx context.Context +} + +func prependKeyName(key string) string { + return "gmp_cache:" + key +} + +type RedisClientConfig struct { + RedisServer string + RedisPassword string + RedisDB int +} + +func NewClient(redisClientConfig *RedisClientConfig) *RedisConfig { + c := &RedisConfig{ + client: redis.NewClient(&redis.Options{ + Addr: redisClientConfig.RedisServer, + Password: redisClientConfig.RedisPassword, + DB: redisClientConfig.RedisDB, + }), + ctx: context.Background(), + } + return c +} + +func (c *RedisConfig) Set(key string, value []byte, ttl time.Duration) { + c.client.Set(c.ctx, prependKeyName(key), value, ttl) +} + +func (c *RedisConfig) Get(key string) ([]byte, bool) { + val, err := c.client.Get(c.ctx, prependKeyName(key)).Result() + if err == redis.Nil || err != nil { + return nil, false + } + return []byte(val), true +} + +func (c *RedisConfig) Delete(key string) { + c.client.Del(c.ctx, prependKeyName(key)) +} + +func (c *RedisConfig) Clear() { + c.client.FlushDB(c.ctx) +} + +func (c *RedisConfig) CountQueries() int { + keys, err := c.client.Keys(c.ctx, prependKeyName("*")).Result() + if err != nil { + return 0 + } + return len(keys) +} + +func (c *RedisConfig) CountQueriesWithPattern(pattern string) int { + keys, err := c.client.Keys(c.ctx, prependKeyName(pattern)).Result() + if err != nil { + return 0 + } + return len(keys) +} diff --git a/cache/redis/connection_test.go b/cache/redis/connection_test.go new file mode 100644 index 0000000..cb76c73 --- /dev/null +++ b/cache/redis/connection_test.go @@ -0,0 +1,123 @@ +package libpack_redis + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type RedisConfigSuite struct { + suite.Suite + redisConfig *RedisConfig +} + +func (suite *RedisConfigSuite) SetupTest() { + suite.redisConfig = NewClient(&RedisClientConfig{ + RedisServer: "localhost:6379", + RedisPassword: "", + RedisDB: 0, + }) + suite.redisConfig.Delete("testkey") +} + +func TestRedisConfigSuite(t *testing.T) { + suite.Run(t, new(RedisConfigSuite)) +} + +func (suite *RedisConfigSuite) TestSet() { + key := "testkey" + value := []byte("testvalue") + suite.redisConfig.Delete(key) // Ensure the key is deleted before the test + + // Test writing a new key-value pair + suite.redisConfig.Set(key, value, 0) + storedValue, found := suite.redisConfig.Get(key) + assert.True(suite.T(), found) + assert.Equal(suite.T(), value, storedValue) + + // Test overwriting an existing key-value pair + newValue := []byte("newvalue") + suite.redisConfig.Set(key, newValue, 0) + storedValue, found = suite.redisConfig.Get(key) + assert.True(suite.T(), found) + assert.Equal(suite.T(), newValue, storedValue) + + suite.redisConfig.Delete(key) // Clean up after the test +} + +func (suite *RedisConfigSuite) TestSetWithExpiry() { + key := "testkey" + value := []byte("testvalue") + expiry := 1 * time.Second + suite.redisConfig.Delete(key) // Ensure the key is deleted before the test + + // Test writing a new key-value pair + suite.redisConfig.Set(key, value, expiry) + storedValue, found := suite.redisConfig.Get(key) + assert.True(suite.T(), found) + assert.Equal(suite.T(), value, storedValue) + + // Test that key expires after the specified time + time.Sleep(2 * time.Second) + _, found = suite.redisConfig.Get(key) + assert.False(suite.T(), found) + + suite.redisConfig.Delete(key) // Clean up after the test +} + +func (suite *RedisConfigSuite) TestGet() { + key := "testkey" + value := []byte("testvalue") + suite.redisConfig.Set(key, value, 0) // Set the key-value pair + storedValue, found := suite.redisConfig.Get(key) + assert.True(suite.T(), found) + assert.Equal(suite.T(), value, storedValue) +} + +func (suite *RedisConfigSuite) TestDeleteKey() { + key := "testkey" + value := []byte("testvalue") + suite.redisConfig.Set(key, value, 0) // Set the key-value pair + suite.redisConfig.Delete(key) + _, found := suite.redisConfig.Get(key) + assert.False(suite.T(), found) +} + +func (suite *RedisConfigSuite) TestCheckIfKeyExists() { + ttl := time.Duration(10) * time.Second + key := "testkey" + value := []byte("testvalue") + suite.redisConfig.Set(key, value, ttl) // Set the key-value pair + _, found := suite.redisConfig.Get(key) + assert.True(suite.T(), found) + + suite.redisConfig.Delete(key) + _, found = suite.redisConfig.Get(key) + assert.False(suite.T(), found) +} + +func (suite *RedisConfigSuite) TestGetKeys() { + ttl := time.Duration(10) * time.Second + suite.redisConfig.Set("testkey1", []byte("testvalue1"), ttl) + suite.redisConfig.Set("testkey2", []byte("testvalue2"), ttl) + suite.redisConfig.Set("otherkey", []byte("othervalue"), ttl) + + keys, _ := suite.redisConfig.client.Keys(suite.redisConfig.ctx, prependKeyName("testkey*")).Result() + expectedKeys := []string{prependKeyName("testkey1"), prependKeyName("testkey2")} + assert.ElementsMatch(suite.T(), expectedKeys, keys) + + suite.redisConfig.client.Del(suite.redisConfig.ctx, "testkey1", "testkey2", "otherkey") +} + +func (suite *RedisConfigSuite) TestGetKeysCount() { + ttl := time.Duration(10) * time.Second + suite.redisConfig.Set("testkey1", []byte("testvalue1"), ttl) + suite.redisConfig.Set("testkey2", []byte("testvalue2"), ttl) + suite.redisConfig.Set("otherkey", []byte("othervalue"), ttl) + + assert.Equal(suite.T(), 2, suite.redisConfig.CountQueriesWithPattern("testkey*")) + + suite.redisConfig.client.Del(suite.redisConfig.ctx, "testkey1", "testkey2", "otherkey") +} diff --git a/cache_test.go b/cache_test.go index ca2ef96..822abb5 100644 --- a/cache_test.go +++ b/cache_test.go @@ -1,10 +1,8 @@ package main -import ( - "time" -) +import libpack_redis "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/redis" -func (suite *Tests) Test_cacheLookup() { +func (suite *Tests) Test_cacheLookupInmemory() { type args struct { hash string } @@ -39,7 +37,56 @@ func (suite *Tests) Test_cacheLookup() { for _, tt := range tests { suite.Run(tt.name, func() { if tt.addCache.data != nil { - cfg.Cache.CacheClient.Set(tt.args.hash, tt.addCache.data, time.Duration(90*time.Second)) + cacheStore(tt.args.hash, tt.addCache.data) + } + got := cacheLookup(tt.args.hash) + assert.Equal(tt.want, got, "Unexpected cache lookup result") + }) + } +} + +func (suite *Tests) Test_cacheLookupRedis() { + cfg.Cache.Client = libpack_redis.NewClient(&libpack_redis.RedisClientConfig{ + RedisDB: 0, + RedisServer: "localhost:6379", + RedisPassword: "", + }) + + type args struct { + hash string + } + tests := []struct { + name string + args args + want []byte + addCache struct { + data []byte + } + }{ + { + name: "test_non_existent", + args: args{ + hash: "00000000000000000000000000000000000000", + }, + want: nil, + }, + { + name: "test_existent", + args: args{ + hash: "00000000000000000000000000000000001337", + }, + want: []byte("it's fine."), + addCache: struct { + data []byte + }{ + data: []byte("it's fine."), + }, + }, + } + for _, tt := range tests { + suite.Run(tt.name, func() { + if tt.addCache.data != nil { + cacheStore(tt.args.hash, tt.addCache.data) } got := cacheLookup(tt.args.hash) assert.Equal(tt.want, got, "Unexpected cache lookup result") diff --git a/go.mod b/go.mod index 3e0740d..5d29be7 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415 github.com/lukaszraczylo/go-ratecounter v0.1.8 github.com/lukaszraczylo/go-simple-graphql v1.2.14 + github.com/redis/go-redis/v9 v9.5.3 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.9.0 github.com/valyala/fasthttp v1.54.0 @@ -21,7 +22,9 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gookit/color v1.5.4 // indirect github.com/klauspost/compress v1.17.8 // indirect github.com/kr/pretty v0.3.1 // indirect diff --git a/go.sum b/go.sum index e10911c..1e72cfa 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,18 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/avast/retry-go/v4 v4.6.0 h1:K9xNA+KeB8HHc2aWFuLb25Offp+0iVRXEvFx8IinRJA= github.com/avast/retry-go/v4 v4.6.0/go.mod h1:gvWlPhBVsvBbLkVGDg/KwvBv0bEkCOLRRSHKIr2PyOE= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -50,6 +58,8 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU= +github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/main.go b/main.go index a9cbd5d..02fcc1b 100644 --- a/main.go +++ b/main.go @@ -46,8 +46,14 @@ func parseConfig() { c.Client.JWTRoleClaimPath = getDetailsFromEnv("JWT_ROLE_CLAIM_PATH", "") c.Client.RoleFromHeader = getDetailsFromEnv("ROLE_FROM_HEADER", "") c.Client.RoleRateLimit = getDetailsFromEnv("ROLE_RATE_LIMIT", false) + /* in-memory cache */ c.Cache.CacheEnable = getDetailsFromEnv("ENABLE_GLOBAL_CACHE", false) c.Cache.CacheTTL = getDetailsFromEnv("CACHE_TTL", 60) + /* redis cache */ + c.Cache.CacheRedisEnable = getDetailsFromEnv("ENABLE_REDIS_CACHE", false) + c.Cache.CacheRedisURL = getDetailsFromEnv("CACHE_REDIS_URL", "localhost:6379") + c.Cache.CacheRedisPassword = getDetailsFromEnv("CACHE_REDIS_PASSWORD", "") + c.Cache.CacheRedisDB = getDetailsFromEnv("CACHE_REDIS_DB", 0) c.Security.BlockIntrospection = getDetailsFromEnv("BLOCK_SCHEMA_INTROSPECTION", false) c.Security.IntrospectionAllowed = func() []string { urls := getDetailsFromEnv("ALLOWED_INTROSPECTION", "") diff --git a/main_test.go b/main_test.go index 66fcab7..864f5cb 100644 --- a/main_test.go +++ b/main_test.go @@ -3,9 +3,11 @@ package main import ( "os" "testing" + "time" "github.com/goccy/go-json" "github.com/gofiber/fiber/v2" + libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/memory" libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" assertions "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -32,6 +34,10 @@ func (suite *Tests) SetupTest() { JSONDecoder: json.Unmarshal, }, ) + cacheStats = &CacheStats{} + + // Initialize a simple in-memory cache client for testing purposes + cfg.Cache.Client = libpack_cache.New(5 * time.Minute) parseConfig() enableApi() StartMonitoringServer() diff --git a/server.go b/server.go index 516f2dc..375cb2a 100644 --- a/server.go +++ b/server.go @@ -126,6 +126,8 @@ func processGraphQLRequest(c *fiber.Ctx) error { return proxyTheRequest(c, parsedResult.activeEndpoint) } + calculatedQueryHash := calculateHash(c) + if parsedResult.cacheTime > 0 { cfg.Logger.Debug("Cache time set via query", map[string]interface{}{"cacheTime": parsedResult.cacheTime}) } else { @@ -143,19 +145,24 @@ func processGraphQLRequest(c *fiber.Ctx) error { if parsedResult.cacheRefresh { cfg.Logger.Debug("Cache refresh requested via query", map[string]interface{}{"user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")}) - cacheDelete(calculateHash(c)) + cacheDelete(calculatedQueryHash) } // Handling Cache Logic - if parsedResult.cacheRequest || cfg.Cache.CacheEnable { + if parsedResult.cacheRequest || cfg.Cache.CacheEnable || cfg.Cache.CacheRedisEnable { cfg.Logger.Debug("Cache enabled", map[string]interface{}{"via_query": parsedResult.cacheRequest, "via_env": cfg.Cache.CacheEnable}) - queryCacheHash = calculateHash(c) + queryCacheHash = calculatedQueryHash if cachedResponse := cacheLookup(queryCacheHash); cachedResponse != nil { cfg.Monitoring.Increment(libpack_monitoring.MetricsCacheHit, nil) cfg.Logger.Debug("Cache hit", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")}) c.Request().Header.Add("X-Cache-Hit", "true") - c.Send(cachedResponse) + err := c.Send(cachedResponse) + if err != nil { + cfg.Logger.Error("Can't send the cached response", map[string]interface{}{"error": err.Error()}) + cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil) + c.Status(500).SendString("Can't send the cached response - try again later") + } wasCached = true } else { cfg.Monitoring.Increment(libpack_monitoring.MetricsCacheMiss, nil) @@ -163,7 +170,13 @@ func processGraphQLRequest(c *fiber.Ctx) error { proxyAndCacheTheRequest(c, queryCacheHash, parsedResult.cacheTime, parsedResult.activeEndpoint) } } else { - proxyTheRequest(c, parsedResult.activeEndpoint) + err := proxyTheRequest(c, parsedResult.activeEndpoint) + if err != nil { + cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": err.Error()}) + cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil) + c.Status(500).SendString("Can't proxy the request - try again later") + return nil + } } timeTaken := time.Since(startTime) @@ -183,7 +196,7 @@ func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime int, c.Status(500).SendString("Can't proxy the request - try again later") return } - cfg.Cache.CacheClient.Set(queryCacheHash, c.Response().Body(), time.Duration(cacheTime)*time.Second) + cacheStoreWithTTL(queryCacheHash, c.Response().Body(), time.Duration(cacheTime)*time.Second) cfg.Monitoring.Increment(libpack_monitoring.MetricsQueriesCached, nil) c.Send(c.Response().Body()) } diff --git a/struct_config.go b/struct_config.go index 81a2c66..5b9e074 100644 --- a/struct_config.go +++ b/struct_config.go @@ -2,7 +2,6 @@ package main import ( graphql "github.com/lukaszraczylo/go-simple-graphql" - libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache" libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring" "github.com/valyala/fasthttp" @@ -11,9 +10,13 @@ import ( // config is a struct that holds the configuration of the application. type config struct { Cache struct { - CacheClient *libpack_cache.Cache - CacheTTL int - CacheEnable bool + Client CacheClient + CacheTTL int + CacheEnable bool + CacheRedisEnable bool + CacheRedisURL string + CacheRedisPassword string + CacheRedisDB int } Logger *libpack_logging.LogConfig Monitoring *libpack_monitoring.MetricsSetup