diff --git a/.env b/.env index 33b42ed..a36acf9 100644 --- a/.env +++ b/.env @@ -120,12 +120,13 @@ CACHE_ENABLED=true # REDIS_ENDPOINT_URL is an url of redis REDIS_ENDPOINT_URL=redis:6379 REDIS_PASSWORD= -# CACHE_TTL_SECONDS is a TTL for cached evm requests -# CACHE_TTL_SECONDS should be specified in seconds -CACHE_TTL_SECONDS=600 -# CACHE_INDEFINITELY overrides CACHE_TTL_SECONDS and sets TTL to infinity -# if CACHE_INDEFINITELY set to true, CACHE_TTL_SECONDS should be zero -CACHE_INDEFINITELY=false +# CACHE__TTL_SECONDS is a TTL for cached evm requests +# CACHE__TTL_SECONDS should be specified in seconds +# refers to group of evm methods, different groups may have different TTLs +# CACHE__TTL_SECONDS should be either greater than zero or equal to -1, -1 means cache indefinitely +CACHE_METHOD_HAS_BLOCK_NUMBER_PARAM_TTL_SECONDS=600 +CACHE_METHOD_HAS_BLOCK_HASH_PARAM_TTL_SECONDS=600 +CACHE_STATIC_METHOD_TTL_SECONDS=600 # CACHE_PREFIX is used as prefix for any key in the cache, key has such structure: # :evm-request::sha256: # Possible values are testnet, mainnet, etc... diff --git a/architecture/CACHING.md b/architecture/CACHING.md index 421e138..a7a5c53 100644 --- a/architecture/CACHING.md +++ b/architecture/CACHING.md @@ -34,7 +34,13 @@ package provides two different middlewares: ## What requests are cached? -As of now we cache requests which has `specific block number` in request, for example: +As of now we have 3 different groups of cacheable EVM methods: +- cacheable by block number (for ex.: `eth_getBlockByNumber`) +- cacheable by block hash (for ex.: `eth_getBlockByHash`) +- static methods (for ex.: `eth_chainId`, `net_version`) + +### Example of cacheable eth_getBlockByNumber method + ```json { "jsonrpc":"2.0", @@ -47,7 +53,14 @@ As of now we cache requests which has `specific block number` in request, for ex } ``` -we don't cache requests without `specific block number` or requests which uses magic tags as a block number: "latest", "pending", "earliest", etc... +NOTE: we don't cache requests which uses magic tags as a block number: "latest", "pending", etc... + +TTL can be specified independently for each group, for ex: +``` +CACHE_METHOD_HAS_BLOCK_NUMBER_PARAM_TTL_SECONDS=600 +CACHE_METHOD_HAS_BLOCK_HASH_PARAM_TTL_SECONDS=1200 +CACHE_STATIC_METHOD_TTL_SECONDS=-1 +``` ## Cache Invalidation diff --git a/clients/cache/cache.go b/clients/cache/cache.go index 2ca43be..00ca8c3 100644 --- a/clients/cache/cache.go +++ b/clients/cache/cache.go @@ -9,7 +9,9 @@ import ( var ErrNotFound = errors.New("value not found in the cache") type Cache interface { - Set(ctx context.Context, key string, data []byte, expiration time.Duration, cacheIndefinitely bool) error + // Set sets the value in the cache with specified expiration. + // Expiration should be either greater than zero or equal to -1, -1 means cache indefinitely. + Set(ctx context.Context, key string, data []byte, expiration time.Duration) error Get(ctx context.Context, key string) ([]byte, error) Delete(ctx context.Context, key string) error Healthcheck(ctx context.Context) error diff --git a/clients/cache/inmemory.go b/clients/cache/inmemory.go index 28e7de2..6d11634 100644 --- a/clients/cache/inmemory.go +++ b/clients/cache/inmemory.go @@ -34,14 +34,14 @@ func (c *InMemoryCache) Set( key string, data []byte, expiration time.Duration, - cacheIndefinitely bool, ) error { c.mutex.Lock() defer c.mutex.Unlock() expiry := time.Now().Add(expiration) - if cacheIndefinitely { + // -1 means cache indefinitely. + if expiration == -1 { // 100 years in the future to prevent expiry expiry = time.Now().AddDate(100, 0, 0) } diff --git a/clients/cache/redis.go b/clients/cache/redis.go index 0a9cc37..395ce08 100644 --- a/clients/cache/redis.go +++ b/clients/cache/redis.go @@ -45,16 +45,15 @@ func (rc *RedisCache) Set( key string, value []byte, expiration time.Duration, - cacheIndefinitely bool, ) error { rc.Logger.Trace(). Str("key", key). Str("value", string(value)). Dur("expiration", expiration). - Bool("cache-indefinitely", cacheIndefinitely). Msg("setting value in redis") - if cacheIndefinitely { + // -1 means cache indefinitely. + if expiration == -1 { // In redis zero expiration means the key has no expiration time. expiration = 0 } diff --git a/config/config.go b/config/config.go index 0552734..6ea94ac 100644 --- a/config/config.go +++ b/config/config.go @@ -45,10 +45,11 @@ type Config struct { MetricPruningRoutineDelayFirstRun time.Duration MetricPruningMaxRequestMetricsHistoryDays int CacheEnabled bool - CacheIndefinitely bool RedisEndpointURL string RedisPassword string - CacheTTL time.Duration + CacheMethodHasBlockNumberParamTTL time.Duration + CacheMethodHasBlockHashParamTTL time.Duration + CacheStaticMethodTTL time.Duration CachePrefix string WhitelistedHeaders []string DefaultAccessControlAllowOriginValue string @@ -110,8 +111,9 @@ const ( CACHE_ENABLED_ENVIRONMENT_KEY = "CACHE_ENABLED" REDIS_ENDPOINT_URL_ENVIRONMENT_KEY = "REDIS_ENDPOINT_URL" REDIS_PASSWORD_ENVIRONMENT_KEY = "REDIS_PASSWORD" - CACHE_INDEFINITELY_KEY = "CACHE_INDEFINITELY" - CACHE_TTL_ENVIRONMENT_KEY = "CACHE_TTL_SECONDS" + CACHE_METHOD_HAS_BLOCK_NUMBER_PARAM_TTL_ENVIRONMENT_KEY = "CACHE_METHOD_HAS_BLOCK_NUMBER_PARAM_TTL_SECONDS" + CACHE_METHOD_HAS_BLOCK_HASH_PARAM_TTL_ENVIRONMENT_KEY = "CACHE_METHOD_HAS_BLOCK_HASH_PARAM_TTL_SECONDS" + CACHE_STATIC_METHOD_TTL_ENVIRONMENT_KEY = "CACHE_STATIC_METHOD_TTL_SECONDS" CACHE_PREFIX_ENVIRONMENT_KEY = "CACHE_PREFIX" WHITELISTED_HEADERS_ENVIRONMENT_KEY = "WHITELISTED_HEADERS" DEFAULT_ACCESS_CONTROL_ALLOW_ORIGIN_VALUE_ENVIRONMENT_KEY = "DEFAULT_ACCESS_CONTROL_ALLOW_ORIGIN_VALUE" @@ -302,8 +304,9 @@ func ReadConfig() Config { CacheEnabled: EnvOrDefaultBool(CACHE_ENABLED_ENVIRONMENT_KEY, false), RedisEndpointURL: os.Getenv(REDIS_ENDPOINT_URL_ENVIRONMENT_KEY), RedisPassword: os.Getenv(REDIS_PASSWORD_ENVIRONMENT_KEY), - CacheTTL: time.Duration(EnvOrDefaultInt(CACHE_TTL_ENVIRONMENT_KEY, 0)) * time.Second, - CacheIndefinitely: EnvOrDefaultBool(CACHE_INDEFINITELY_KEY, false), + CacheMethodHasBlockNumberParamTTL: time.Duration(EnvOrDefaultInt(CACHE_METHOD_HAS_BLOCK_NUMBER_PARAM_TTL_ENVIRONMENT_KEY, 0)) * time.Second, + CacheMethodHasBlockHashParamTTL: time.Duration(EnvOrDefaultInt(CACHE_METHOD_HAS_BLOCK_HASH_PARAM_TTL_ENVIRONMENT_KEY, 0)) * time.Second, + CacheStaticMethodTTL: time.Duration(EnvOrDefaultInt(CACHE_STATIC_METHOD_TTL_ENVIRONMENT_KEY, 0)) * time.Second, CachePrefix: os.Getenv(CACHE_PREFIX_ENVIRONMENT_KEY), WhitelistedHeaders: parsedWhitelistedHeaders, DefaultAccessControlAllowOriginValue: os.Getenv(DEFAULT_ACCESS_CONTROL_ALLOW_ORIGIN_VALUE_ENVIRONMENT_KEY), diff --git a/config/validate.go b/config/validate.go index 516aa71..6f9bca5 100644 --- a/config/validate.go +++ b/config/validate.go @@ -6,6 +6,7 @@ import ( "net/url" "strconv" "strings" + "time" ) var ( @@ -64,12 +65,17 @@ func Validate(config Config) error { if config.RedisEndpointURL == "" { allErrs = errors.Join(allErrs, fmt.Errorf("invalid %s specified %s, must not be empty", REDIS_ENDPOINT_URL_ENVIRONMENT_KEY, config.RedisEndpointURL)) } - if !config.CacheIndefinitely && config.CacheTTL <= 0 { - allErrs = errors.Join(allErrs, fmt.Errorf("invalid %s specified %s, must be greater than zero (when CACHE_INDEFINITELY is false)", CACHE_TTL_ENVIRONMENT_KEY, config.CacheTTL)) + + if err := checkTTLConfig(config.CacheMethodHasBlockNumberParamTTL, CACHE_METHOD_HAS_BLOCK_NUMBER_PARAM_TTL_ENVIRONMENT_KEY); err != nil { + allErrs = errors.Join(allErrs, err) + } + if err := checkTTLConfig(config.CacheMethodHasBlockHashParamTTL, CACHE_METHOD_HAS_BLOCK_HASH_PARAM_TTL_ENVIRONMENT_KEY); err != nil { + allErrs = errors.Join(allErrs, err) } - if config.CacheIndefinitely && config.CacheTTL != 0 { - allErrs = errors.Join(allErrs, fmt.Errorf("invalid %s specified %s, must be zero (when CACHE_INDEFINITELY is true)", CACHE_TTL_ENVIRONMENT_KEY, config.CacheTTL)) + if err := checkTTLConfig(config.CacheStaticMethodTTL, CACHE_STATIC_METHOD_TTL_ENVIRONMENT_KEY); err != nil { + allErrs = errors.Join(allErrs, err) } + if strings.Contains(config.CachePrefix, ":") { allErrs = errors.Join(allErrs, fmt.Errorf("invalid %s specified %s, must not contain colon symbol", CACHE_PREFIX_ENVIRONMENT_KEY, config.CachePrefix)) } @@ -84,6 +90,17 @@ func Validate(config Config) error { return allErrs } +func checkTTLConfig(cacheTTL time.Duration, cacheTTLKey string) error { + if cacheTTL > 0 { + return nil + } + if cacheTTL == -1 { + return nil + } + + return fmt.Errorf("invalid %s specified %s, must be greater than zero or -1", cacheTTLKey, cacheTTL) +} + // validateHostURLMap validates a raw backend host URL map, optionally allowing the map to be empty func validateHostURLMap(raw string, allowEmpty bool) error { _, err := ParseRawProxyBackendHostURLMap(raw) diff --git a/decode/evm_rpc.go b/decode/evm_rpc.go index 5b97d14..bf172bf 100644 --- a/decode/evm_rpc.go +++ b/decode/evm_rpc.go @@ -89,6 +89,23 @@ func MethodHasBlockHashParam(method string) bool { return includesBlockHashParam } +// StaticMethods is a list of static EVM methods which can be cached indefinitely, response will never change. +var StaticMethods = []string{ + "eth_chainId", + "net_version", +} + +// IsMethodStatic checks if method is static. In this context static means that response will never change and can be cached indefinitely. +func IsMethodStatic(method string) bool { + for _, staticMethod := range StaticMethods { + if method == staticMethod { + return true + } + } + + return false +} + // NoHistoryMethods is a list of JSON-RPC methods that rely only on the present state of the chain. // They can always be safely routed to an up-to-date pruning cluster. var NoHistoryMethods = []string{ diff --git a/main_test.go b/main_test.go index a05f6a4..670be52 100644 --- a/main_test.go +++ b/main_test.go @@ -943,3 +943,99 @@ func checkJsonRpcErr(body []byte) error { return nil } + +func TestE2ETestCachingMdwForStaticMethods(t *testing.T) { + // create api and database clients + client, err := ethclient.Dial(proxyServiceURL) + if err != nil { + t.Fatal(err) + } + + redisClient := redis.NewClient(&redis.Options{ + Addr: redisURL, + Password: redisPassword, + DB: 0, + }) + cleanUpRedis(t, redisClient) + expectKeysNum(t, redisClient, 0) + + for _, tc := range []struct { + desc string + method string + params []interface{} + keysNum int + expectedKey string + }{ + { + desc: "test case #1", + method: "eth_chainId", + params: []interface{}{}, + keysNum: 1, + expectedKey: "local-chain:evm-request:eth_chainId:sha256:*", + }, + { + desc: "test case #2", + method: "net_version", + params: []interface{}{}, + keysNum: 2, + expectedKey: "local-chain:evm-request:net_version:sha256:*", + }, + } { + t.Run(tc.desc, func(t *testing.T) { + // test cache MISS and cache HIT scenarios for specified method + // check corresponding values in cachemdw.CacheHeaderKey HTTP header + // check that cached and non-cached responses are equal + + // cache MISS + cacheMissResp := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) + require.Equal(t, cachemdw.CacheMissHeaderValue, cacheMissResp.Header[cachemdw.CacheHeaderKey][0]) + body1, err := io.ReadAll(cacheMissResp.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body1) + require.NoError(t, err) + expectKeysNum(t, redisClient, tc.keysNum) + containsKey(t, redisClient, tc.expectedKey) + + // cache HIT + cacheHitResp := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) + require.Equal(t, cachemdw.CacheHitHeaderValue, cacheHitResp.Header[cachemdw.CacheHeaderKey][0]) + body2, err := io.ReadAll(cacheHitResp.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body2) + require.NoError(t, err) + expectKeysNum(t, redisClient, tc.keysNum) + containsKey(t, redisClient, tc.expectedKey) + + // check that response bodies are the same + require.JSONEq(t, string(body1), string(body2), "blocks should be the same") + + // check that response headers are the same + equalHeaders(t, cacheMissResp.Header, cacheHitResp.Header) + + // check that CORS headers are present for cache hit scenario + require.Equal(t, cacheHitResp.Header[accessControlAllowOriginHeaderName], []string{"*"}) + }) + } + + cleanUpRedis(t, redisClient) + // test cache MISS and cache HIT scenarios for eth_chainId method + // check that cached and non-cached responses are equal + { + // eth_getBlockByNumber - cache MISS + block1, err := client.ChainID(testContext) + require.NoError(t, err) + expectKeysNum(t, redisClient, 1) + expectedKey := "local-chain:evm-request:eth_chainId:sha256:*" + containsKey(t, redisClient, expectedKey) + + // eth_getBlockByNumber - cache HIT + block2, err := client.ChainID(testContext) + require.NoError(t, err) + expectKeysNum(t, redisClient, 1) + containsKey(t, redisClient, expectedKey) + + require.Equal(t, block1, block2, "blocks should be the same") + } + + cleanUpRedis(t, redisClient) +} diff --git a/service/cachemdw/cache.go b/service/cachemdw/cache.go index 02041d5..d97853b 100644 --- a/service/cachemdw/cache.go +++ b/service/cachemdw/cache.go @@ -13,15 +13,20 @@ import ( "github.com/kava-labs/kava-proxy-service/logging" ) +type Config struct { + // TTL for cached evm requests + // Different evm method groups may have different TTLs + // TTL should be either greater than zero or equal to -1, -1 means cache indefinitely + CacheMethodHasBlockNumberParamTTL time.Duration + CacheMethodHasBlockHashParamTTL time.Duration + CacheStaticMethodTTL time.Duration +} + // ServiceCache is responsible for caching EVM requests and provides corresponding middleware // ServiceCache can work with any underlying storage which implements simple cache.Cache interface type ServiceCache struct { - cacheClient cache.Cache - blockGetter decode.EVMBlockGetter - // TTL for cached evm requests - cacheTTL time.Duration - // if cacheIndefinitely set to true it overrides cacheTTL and sets TTL to infinity - cacheIndefinitely bool + cacheClient cache.Cache + blockGetter decode.EVMBlockGetter decodedRequestContextKey any // cachePrefix is used as prefix for any key in the cache cachePrefix string @@ -31,33 +36,33 @@ type ServiceCache struct { defaultAccessControlAllowOriginValue string hostnameToAccessControlAllowOriginValueMap map[string]string + config *Config + *logging.ServiceLogger } func NewServiceCache( cacheClient cache.Cache, blockGetter decode.EVMBlockGetter, - cacheTTL time.Duration, - cacheIndefinitely bool, decodedRequestContextKey any, cachePrefix string, cacheEnabled bool, whitelistedHeaders []string, defaultAccessControlAllowOriginValue string, hostnameToAccessControlAllowOriginValueMap map[string]string, + config *Config, logger *logging.ServiceLogger, ) *ServiceCache { return &ServiceCache{ cacheClient: cacheClient, blockGetter: blockGetter, - cacheTTL: cacheTTL, - cacheIndefinitely: cacheIndefinitely, decodedRequestContextKey: decodedRequestContextKey, cachePrefix: cachePrefix, cacheEnabled: cacheEnabled, whitelistedHeaders: whitelistedHeaders, defaultAccessControlAllowOriginValue: defaultAccessControlAllowOriginValue, hostnameToAccessControlAllowOriginValueMap: hostnameToAccessControlAllowOriginValueMap, + config: config, ServiceLogger: logger, } } @@ -80,10 +85,6 @@ func IsCacheable( return false } - if decode.MethodHasBlockHashParam(req.Method) { - return true - } - if decode.MethodHasBlockNumberParam(req.Method) { blockNumber, err := decode.ParseBlockNumberFromParams(req.Method, req.Params) if err != nil { @@ -98,9 +99,34 @@ func IsCacheable( return blockNumber > 0 || blockNumber == decode.BlockTagToNumberCodec[decode.BlockTagEarliest] } + if decode.MethodHasBlockHashParam(req.Method) { + return true + } + + if decode.IsMethodStatic(req.Method) { + return true + } + return false } +// GetTTL returns TTL for specified EVM method. +func (c *ServiceCache) GetTTL(method string) (time.Duration, error) { + if decode.MethodHasBlockNumberParam(method) { + return c.config.CacheMethodHasBlockNumberParamTTL, nil + } + + if decode.MethodHasBlockHashParam(method) { + return c.config.CacheMethodHasBlockHashParamTTL, nil + } + + if decode.IsMethodStatic(method) { + return c.config.CacheStaticMethodTTL, nil + } + + return 0, ErrRequestIsNotCacheable +} + // GetCachedQueryResponse calculates cache key for request and then tries to get it from cache. // NOTE: only JSON-RPC response's result will be taken from the cache. // JSON-RPC response's ID and Version will be constructed on the fly to match JSON-RPC request. @@ -190,7 +216,12 @@ func (c *ServiceCache) CacheQueryResponse( return err } - return c.cacheClient.Set(ctx, key, queryResponseInJSON, c.cacheTTL, c.cacheIndefinitely) + cacheTTL, err := c.GetTTL(req.Method) + if err != nil { + return fmt.Errorf("can't get cache TTL for %v method: %v", req.Method, err) + } + + return c.cacheClient.Set(ctx, key, queryResponseInJSON, cacheTTL) } func (c *ServiceCache) Healthcheck(ctx context.Context) error { diff --git a/service/cachemdw/cache_test.go b/service/cachemdw/cache_test.go index 309f808..7cc0cc5 100644 --- a/service/cachemdw/cache_test.go +++ b/service/cachemdw/cache_test.go @@ -23,6 +23,12 @@ const ( var ( defaultQueryResp = []byte(testEVMQueries[TestRequestWeb3ClientVersion].ResponseBody) + + defaultConfig = cachemdw.Config{ + CacheMethodHasBlockNumberParamTTL: time.Hour, + CacheMethodHasBlockHashParamTTL: time.Hour, + CacheStaticMethodTTL: time.Hour, + } ) type MockEVMBlockGetter struct{} @@ -70,21 +76,18 @@ func TestUnitTestCacheQueryResponse(t *testing.T) { inMemoryCache := cache.NewInMemoryCache() blockGetter := NewMockEVMBlockGetter() - cacheTTL := time.Hour - cacheIndefinitely := false ctxb := context.Background() serviceCache := cachemdw.NewServiceCache( inMemoryCache, blockGetter, - cacheTTL, - cacheIndefinitely, service.DecodedRequestContextKey, defaultCachePrefixString, true, []string{}, "*", map[string]string{}, + &defaultConfig, &logger, ) diff --git a/service/cachemdw/middleware_test.go b/service/cachemdw/middleware_test.go index 2664e9a..75a9597 100644 --- a/service/cachemdw/middleware_test.go +++ b/service/cachemdw/middleware_test.go @@ -5,7 +5,6 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/require" @@ -22,20 +21,17 @@ func TestUnitTestServiceCacheMiddleware(t *testing.T) { inMemoryCache := cache.NewInMemoryCache() blockGetter := NewMockEVMBlockGetter() - cacheTTL := time.Duration(0) - cacheIndefinitely := true serviceCache := cachemdw.NewServiceCache( inMemoryCache, blockGetter, - cacheTTL, - cacheIndefinitely, service.DecodedRequestContextKey, defaultCachePrefixString, true, []string{}, "*", map[string]string{}, + &defaultConfig, &logger, ) @@ -110,20 +106,17 @@ func TestUnitTestServiceCacheMiddleware_CacheIsDisabled(t *testing.T) { inMemoryCache := cache.NewInMemoryCache() blockGetter := NewMockEVMBlockGetter() - cacheTTL := time.Duration(0) - cacheIndefinitely := true serviceCache := cachemdw.NewServiceCache( inMemoryCache, blockGetter, - cacheTTL, - cacheIndefinitely, service.DecodedRequestContextKey, defaultCachePrefixString, false, []string{}, "*", map[string]string{}, + &defaultConfig, &logger, ) diff --git a/service/service.go b/service/service.go index e8a2b64..f9281bb 100644 --- a/service/service.go +++ b/service/service.go @@ -207,17 +207,22 @@ func createServiceCache( return nil, err } + cacheConfig := cachemdw.Config{ + CacheMethodHasBlockNumberParamTTL: config.CacheMethodHasBlockNumberParamTTL, + CacheMethodHasBlockHashParamTTL: config.CacheMethodHasBlockHashParamTTL, + CacheStaticMethodTTL: config.CacheStaticMethodTTL, + } + serviceCache := cachemdw.NewServiceCache( redisCache, evmclient, - config.CacheTTL, - config.CacheIndefinitely, DecodedRequestContextKey, config.CachePrefix, config.CacheEnabled, config.WhitelistedHeaders, config.DefaultAccessControlAllowOriginValue, config.HostnameToAccessControlAllowOriginValueMap, + &cacheConfig, logger, )