Skip to content

Commit

Permalink
Basic implementation of Caching Middleware (#39)
Browse files Browse the repository at this point in the history
* Basic implementation of Caching Middleware

* Minor fix

* Added comments

* Update CACHING.md

* Update CACHING.md

* Update CACHING.md

* Add response parser

* Integrate response parser and adjust tests accordingly

* Fix tests

* Improve tests

* CR's fixes

* CR's fixes

* Change Key Structure

* Update CACHING.md

* Rename ChainID -> CachePrefix and add validation

* Add is_cache_enabled boolean flag

* Added test for cache disabled scenario

* CR's fixes

* Improve tests

* Update healthcheck

* CR's fixes

* CR's fixes: improve logging

* Fixes after merge

* Improve IsCacheable method

* Improve logging

* CR's fixes

* Fix JSON-RPC response's ID flow

* Remove deprecated TODO

* Small fix
  • Loading branch information
evgeniy-scherbina authored Oct 20, 2023
1 parent 5ba3572 commit 956b227
Show file tree
Hide file tree
Showing 26 changed files with 2,188 additions and 31 deletions.
15 changes: 15 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ TEST_SERVICE_LOG_LEVEL=ERROR
# endpoint the proxy service should use for querying
# evm blockchain information related to proxied requests
TEST_EVM_QUERY_SERVICE_URL=http://kava-validator:8545
# TEST_REDIS_ENDPOINT_URL is an url of redis
TEST_REDIS_ENDPOINT_URL=localhost:6379

##### Kava Node Config

Expand Down Expand Up @@ -100,6 +102,19 @@ METRIC_PARTITIONING_ROUTINE_DELAY_FIRST_RUN_SECONDS=10
METRIC_PARTITIONINING_PREFILL_PERIOD_DAYS=7
# Used by `ready` script to ensure metric partitions have been created.
MINIMUM_REQUIRED_PARTITIONS=30
# CACHE_ENABLED specifies if cache should be enabled. By default cache is disabled.
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_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...
# CACHE_PREFIX must not contain colon symbol
CACHE_PREFIX=local-chain

##### Database Config
POSTGRES_PASSWORD=password
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ kava-proxy-service
cover.html

# Dependency directories (remove the comment below to include it)
# vendor/
vendor/

# ignore editor files
.vscode/
.idea/

# ignore e2e test validator files
docker/shared/genesis.json
Expand Down
90 changes: 90 additions & 0 deletions architecture/CACHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
## Caching Middleware Architecture

Package `cachemdw` is responsible for caching EVM requests and provides corresponding middleware

package can work with any underlying storage which implements simple `cache.Cache` interface

package provides two different middlewares:
- `IsCachedMiddleware` (should be run before proxy middleware)
- `CachingMiddleware` (should be run after proxy middleware)

`IsCachedMiddleware` is responsible for setting response in the context if it's in the cache

`CachingMiddleware` is responsible for caching response by taking a value from context (should be set by `ProxyMiddleware`) and setting in the cache

## CachingMiddleware

`CachingMiddleware` returns kava-proxy-service compatible middleware which works in the following way:
- tries to get decoded request from context (previous middleware should set it)
- checks few conditions:
- if request isn't already cached
- if request is cacheable
- if response is present in context
- if all above is true - caches the response
- calls next middleware

## IsCachedMiddleware

`IsCachedMiddleware` returns kava-proxy-service compatible middleware which works in the following way:
- tries to get decoded request from context (previous middleware should set it)
- tries to get response from the cache
- if present sets cached response in context, marks as cached in context and forwards to next middleware
- if not present marks as uncached in context and forwards to next middleware
- next middleware should check whether request was cached and act accordingly:

## What requests are cached?

As of now we cache requests which has `specific block number` in request, for example:
```json
{
"jsonrpc":"2.0",
"method":"eth_getBlockByNumber",
"params":[
"0x1b4", // specific block number
true
],
"id":1
}
```

we don't cache requests without `specific block number` or requests which uses magic tags as a block number: "latest", "pending", "earliest", etc...

## Cache Invalidation

### Keys Structure

Keys have such format:

`<cache_prefix>:evm-request:<method_name>:sha256:<sha256(body)>`

For example:

`local-chain:evm-request:eth_getBlockByHash:sha256:2db366278f2cb463f92147bd888bdcad528b44baa94b7920fdff35f4c11ee617`

### Invalidation for specific method

If you want to invalidate cache for specific method you may run such command:

`redis-cli KEYS "<cache_prefix>:evm-request:<method_name>:sha256:*" | xargs redis-cli DEL`

For example:

`redis-cli KEYS "local-chain:evm-request:eth_getBlockByNumber:sha256:*" | xargs redis-cli DEL`

### Invalidation for all methods

If you want to invalidate cache for all methods you may run such command:

`redis-cli KEYS "<cache_prefix>:evm-request:*" | xargs redis-cli DEL`

For example:

`redis-cli KEYS "local-chain:evm-request:*" | xargs redis-cli DEL`

## Architecture Diagrams

### Serve request from the cache (avoiding call to actual backend)
![image](https://github.com/Kava-Labs/kava-proxy-service/assets/37836031/1bd8cb8e-6a9e-45a6-b698-3f99eaab2aa2)

### Serve request from the backend and then cache the response
![image](https://github.com/Kava-Labs/kava-proxy-service/assets/37836031/b0eb5cb9-51da-43f9-bb7d-b94bf482f366)
16 changes: 16 additions & 0 deletions clients/cache/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package cache

import (
"context"
"errors"
"time"
)

var ErrNotFound = errors.New("value not found in the cache")

type Cache interface {
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
}
101 changes: 101 additions & 0 deletions clients/cache/inmemory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package cache

import (
"context"
"sync"
"time"
)

// InMemoryCache is an in-memory implementation of the Cache interface.
type InMemoryCache struct {
data map[string]cacheItem
mutex sync.RWMutex
}

// Ensure InMemoryCache implements the Cache interface.
var _ Cache = (*InMemoryCache)(nil)

// cacheItem represents an item stored in the cache.
type cacheItem struct {
data []byte
expiration time.Time
}

// NewInMemoryCache creates a new instance of InMemoryCache.
func NewInMemoryCache() *InMemoryCache {
return &InMemoryCache{
data: make(map[string]cacheItem),
}
}

// Set sets the value of a key in the cache.
func (c *InMemoryCache) Set(
ctx context.Context,
key string,
data []byte,
expiration time.Duration,
) error {
c.mutex.Lock()
defer c.mutex.Unlock()

expiry := time.Now().Add(expiration)

if expiration == 0 {
// 100 years in the future to prevent expiry
expiry = time.Now().AddDate(100, 0, 0)
}

c.data[key] = cacheItem{
data: data,
expiration: expiry,
}

return nil
}

// Get retrieves the value of a key from the cache.
func (c *InMemoryCache) Get(ctx context.Context, key string) ([]byte, error) {
c.mutex.RLock()
defer c.mutex.RUnlock()

item, ok := c.data[key]
if !ok || time.Now().After(item.expiration) {
// Not a real ttl but just replicates it for fetching
delete(c.data, key)

return nil, ErrNotFound
}

return item.data, nil
}

// GetAll returns all the non-expired data in the cache.
func (c *InMemoryCache) GetAll(ctx context.Context) map[string][]byte {
c.mutex.RLock()
defer c.mutex.RUnlock()

result := make(map[string][]byte)

for key, item := range c.data {
if time.Now().After(item.expiration) {
delete(c.data, key)
} else {
result[key] = item.data
}
}

return result
}

// Delete removes a key from the cache.
func (c *InMemoryCache) Delete(ctx context.Context, key string) error {
c.mutex.Lock()
defer c.mutex.Unlock()

delete(c.data, key)
return nil
}

func (c *InMemoryCache) Healthcheck(ctx context.Context) error {
return nil
}
114 changes: 114 additions & 0 deletions clients/cache/redis.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package cache

import (
"context"
"fmt"
"time"

"github.com/kava-labs/kava-proxy-service/logging"
"github.com/redis/go-redis/v9"
)

type RedisConfig struct {
Address string
Password string
DB int
}

// RedisCache is an implementation of Cache that uses Redis as the caching backend.
type RedisCache struct {
client *redis.Client
*logging.ServiceLogger
}

var _ Cache = (*RedisCache)(nil)

func NewRedisCache(
cfg *RedisConfig,
logger *logging.ServiceLogger,
) (*RedisCache, error) {
client := redis.NewClient(&redis.Options{
Addr: cfg.Address,
Password: cfg.Password,
DB: cfg.DB,
})

return &RedisCache{
client: client,
ServiceLogger: logger,
}, nil
}

// Set sets the value for the given key in the cache with the given expiration.
func (rc *RedisCache) Set(
ctx context.Context,
key string,
value []byte,
expiration time.Duration,
) error {
rc.Logger.Trace().
Str("key", key).
Str("value", string(value)).
Dur("expiration", expiration).
Msg("setting value in redis")

return rc.client.Set(ctx, key, value, expiration).Err()
}

// Get gets the value for the given key in the cache.
func (rc *RedisCache) Get(
ctx context.Context,
key string,
) ([]byte, error) {
rc.Logger.Trace().
Str("key", key).
Msg("getting value from redis")

val, err := rc.client.Get(ctx, key).Bytes()
if err == redis.Nil {
rc.Logger.Trace().
Str("key", key).
Msgf("value not found in redis")
return nil, ErrNotFound
}
if err != nil {
rc.Logger.Error().
Str("key", key).
Err(err).
Msg("error during getting value from redis")
return nil, err
}

rc.Logger.Trace().
Str("key", key).
Str("value", string(val)).
Msg("successfully got value from redis")

return val, nil
}

// Delete deletes the value for the given key in the cache.
func (rc *RedisCache) Delete(ctx context.Context, key string) error {
rc.Logger.Trace().
Str("key", key).
Msg("deleting value from redis")

return rc.client.Del(ctx, key).Err()
}

func (rc *RedisCache) Healthcheck(ctx context.Context) error {
rc.Logger.Trace().Msg("redis healthcheck was called")

// Check if we can connect to Redis
_, err := rc.client.Ping(ctx).Result()
if err != nil {
rc.Logger.Error().
Err(err).
Msg("can't ping redis")
return fmt.Errorf("error connecting to Redis: %v", err)
}

rc.Logger.Trace().Msg("redis healthcheck was successful")

return nil
}
Loading

0 comments on commit 956b227

Please sign in to comment.