Skip to content

Commit

Permalink
Basic implementation of caching static methods (#62)
Browse files Browse the repository at this point in the history
* Basic implementation of caching static methods

* Change TTL config structure

* Update CACHING.md
  • Loading branch information
evgeniy-scherbina authored Nov 8, 2023
1 parent d45ce1d commit 274efb2
Show file tree
Hide file tree
Showing 13 changed files with 234 additions and 54 deletions.
13 changes: 7 additions & 6 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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_<group-name>_TTL_SECONDS is a TTL for cached evm requests
# CACHE_<group-name>_TTL_SECONDS should be specified in seconds
# <group-name> refers to group of evm methods, different groups may have different TTLs
# CACHE_<group-name>_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:
# <cache_prefix>:evm-request:<method_name>:sha256:<sha256(body)>
# Possible values are testnet, mainnet, etc...
Expand Down
17 changes: 15 additions & 2 deletions architecture/CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion clients/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions clients/cache/inmemory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 2 additions & 3 deletions clients/cache/redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
15 changes: 9 additions & 6 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
25 changes: 21 additions & 4 deletions config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/url"
"strconv"
"strings"
"time"
)

var (
Expand Down Expand Up @@ -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))
}
Expand All @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions decode/evm_rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
96 changes: 96 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading

0 comments on commit 274efb2

Please sign in to comment.