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

Basic implementation of Caching Middleware #39

Merged
merged 30 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4c4b5c6
Basic implementation of Caching Middleware
evgeniy-scherbina Oct 6, 2023
15a5e50
Minor fix
evgeniy-scherbina Oct 17, 2023
9a431d9
Added comments
evgeniy-scherbina Oct 17, 2023
1f5e85e
Update CACHING.md
evgeniy-scherbina Oct 17, 2023
de33eb9
Update CACHING.md
evgeniy-scherbina Oct 17, 2023
f5cb0c5
Update CACHING.md
evgeniy-scherbina Oct 17, 2023
b1407ec
Add response parser
evgeniy-scherbina Oct 18, 2023
ec6a41c
Integrate response parser and adjust tests accordingly
evgeniy-scherbina Oct 18, 2023
18c59bf
Fix tests
evgeniy-scherbina Oct 18, 2023
199fb9a
Improve tests
evgeniy-scherbina Oct 18, 2023
8039d95
CR's fixes
evgeniy-scherbina Oct 18, 2023
40d0c59
CR's fixes
evgeniy-scherbina Oct 18, 2023
952fdc4
Change Key Structure
evgeniy-scherbina Oct 18, 2023
e1ed01e
Update CACHING.md
evgeniy-scherbina Oct 18, 2023
667fa36
Rename ChainID -> CachePrefix and add validation
evgeniy-scherbina Oct 18, 2023
9ff12c4
Add is_cache_enabled boolean flag
evgeniy-scherbina Oct 19, 2023
3b2b122
Added test for cache disabled scenario
evgeniy-scherbina Oct 19, 2023
8416e19
CR's fixes
evgeniy-scherbina Oct 19, 2023
496113c
Improve tests
evgeniy-scherbina Oct 19, 2023
4706a07
Update healthcheck
evgeniy-scherbina Oct 19, 2023
00581b0
CR's fixes
evgeniy-scherbina Oct 19, 2023
853b726
CR's fixes: improve logging
evgeniy-scherbina Oct 19, 2023
58e743c
Merge remote-tracking branch 'origin/main' into yevhenii/cache
evgeniy-scherbina Oct 19, 2023
50e72d8
Fixes after merge
evgeniy-scherbina Oct 19, 2023
07b4347
Improve IsCacheable method
evgeniy-scherbina Oct 20, 2023
4e5db11
Improve logging
evgeniy-scherbina Oct 20, 2023
f9fbfe5
CR's fixes
evgeniy-scherbina Oct 20, 2023
00ed140
Fix JSON-RPC response's ID flow
evgeniy-scherbina Oct 20, 2023
6cb7f29
Remove deprecated TODO
evgeniy-scherbina Oct 20, 2023
064faf8
Small fix
evgeniy-scherbina Oct 20, 2023
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
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
evgeniy-scherbina marked this conversation as resolved.
Show resolved Hide resolved

## 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()
evgeniy-scherbina marked this conversation as resolved.
Show resolved Hide resolved
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)
evgeniy-scherbina marked this conversation as resolved.
Show resolved Hide resolved
}

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()
evgeniy-scherbina marked this conversation as resolved.
Show resolved Hide resolved
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()
evgeniy-scherbina marked this conversation as resolved.
Show resolved Hide resolved
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,
evgeniy-scherbina marked this conversation as resolved.
Show resolved Hide resolved
) (*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()
evgeniy-scherbina marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading