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

feat: route state-backed requests to archive nodes #65

Merged
merged 16 commits into from
Sep 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions configs/config.sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,24 @@ upstreams:
# it quickly updates the gateway with the latest block height. If this
# setting is undefined, the gateway will attempt to subscribe to new
# heads if the upstream supports it.
# nodeType - full or archive
- id: my-node
httpURL: "http://12.57.207.168:8545"
wsURL: "wss://12.57.207.168:8546"
group: primary
nodeType: full
- id: infura-eth
httpURL: "https://mainnet.infura.io/v3/${INFURA_API_KEY}"
wsURL: "wss://mainnet.infura.io/ws/v3/${INFURA_API_KEY}"
basicAuth:
username: ~
password: ${INFURA_API_KEY_SECRET}
group: fallback
nodeType: archive
- id: alchemy-eth
httpURL: "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}"
wsURL: "wss://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}"
healthCheck:
useWsForBlockHeight: false
group: fallback
nodeType: full
14 changes: 14 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ import (
"gopkg.in/yaml.v3"
)

type NodeType string

const (
Archive NodeType = "archive"
Full NodeType = "full"
)

type UpstreamConfig struct {
BasicAuthConfig BasicAuthConfig `yaml:"basicAuth"`
HealthCheckConfig HealthCheckConfig `yaml:"healthCheck"`
ID string `yaml:"id"`
HTTPURL string `yaml:"httpURL"`
WSURL string `yaml:"wsURL"`
GroupID string `yaml:"group"`
NodeType NodeType `yaml:"nodeType"`
}

func (c *UpstreamConfig) isValid(groups []GroupConfig) bool {
Expand All @@ -25,6 +33,12 @@ func (c *UpstreamConfig) isValid(groups []GroupConfig) bool {
zap.L().Error("httpUrl cannot be empty", zap.Any("config", c), zap.String("upstreamId", c.ID))
}

if c.NodeType == "" {
isValid = false

zap.L().Error("nodeType cannot be empty", zap.Any("config", c), zap.String("upstreamId", c.ID))
}

if c.HealthCheckConfig.UseWSForBlockHeight != nil && *c.HealthCheckConfig.UseWSForBlockHeight && c.WSURL == "" {
isValid = false

Expand Down
8 changes: 6 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,12 @@ func TestParseConfig_ValidConfig(t *testing.T) {
healthCheck:
useWsForBlockHeight: true
group: primary
nodeType: full
- id: ankr-polygon
httpURL: "https://rpc.ankr.com/polygon"
wsURL: "wss://rpc.ankr.com/polygon/ws/${ANKR_API_KEY}"
group: fallback
nodeType: archive
`
configBytes := []byte(config)

Expand All @@ -142,7 +144,8 @@ func TestParseConfig_ValidConfig(t *testing.T) {
HealthCheckConfig: HealthCheckConfig{
UseWSForBlockHeight: newBool(true),
},
GroupID: "primary",
GroupID: "primary",
NodeType: Full,
},
{
ID: "ankr-polygon",
Expand All @@ -151,7 +154,8 @@ func TestParseConfig_ValidConfig(t *testing.T) {
HealthCheckConfig: HealthCheckConfig{
UseWSForBlockHeight: nil,
},
GroupID: "fallback",
GroupID: "fallback",
NodeType: Archive,
},
},
Global: GlobalConfig{
Expand Down
5 changes: 5 additions & 0 deletions internal/metadata/request_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package metadata

type RequestMetadata struct {
IsStateRequired bool
}
21 changes: 21 additions & 0 deletions internal/metadata/request_metadata_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package metadata

import (
"github.com/satsuma-data/node-gateway/internal/jsonrpc"
)

type RequestMetadataParser struct{}

func (p *RequestMetadataParser) Parse(requestBody jsonrpc.RequestBody) RequestMetadata {
result := RequestMetadata{}

switch requestBody.Method {
case "eth_getBalance", "eth_getStorageAt", "eth_getTransactionCount", "eth_getCode", "eth_call", "eth_estimateGas":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we include add a link to the Eth docs that say which methods are state methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, good point! Added.

// List of state methods: https://ethereum.org/en/developers/docs/apis/json-rpc/#state_methods
result.IsStateRequired = true
default:
result.IsStateRequired = false
}

return result
}
42 changes: 42 additions & 0 deletions internal/metadata/request_metadata_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package metadata

import (
"testing"

"github.com/satsuma-data/node-gateway/internal/jsonrpc"
"github.com/stretchr/testify/assert"
)

func TestRequestMetadataParser_Parse(t *testing.T) {
type args struct {
requestBody jsonrpc.RequestBody
}

type testArgs struct {
name string
args args
want RequestMetadata
}

testForMethod := func(methodName string, isStateRequired bool) testArgs {
return testArgs{
methodName,
args{jsonrpc.RequestBody{Method: methodName}},
RequestMetadata{IsStateRequired: isStateRequired},
}
}

tests := []testArgs{
testForMethod("eth_call", true),
testForMethod("eth_getBalance", true),
testForMethod("eth_getBlockByNumber", false),
testForMethod("eth_getTransactionReceipt", false),
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &RequestMetadataParser{}
assert.Equalf(t, tt.want, p.Parse(tt.args.requestBody), "Parse(%v)", tt.args.requestBody)
})
}
}
6 changes: 5 additions & 1 deletion internal/mocks/RoutingStrategy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 12 additions & 5 deletions internal/route/filtering_strategy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package route

import (
"github.com/satsuma-data/node-gateway/internal/config"
"github.com/satsuma-data/node-gateway/internal/metadata"
"github.com/satsuma-data/node-gateway/internal/types"
"go.uber.org/zap"
)
Expand All @@ -11,12 +12,18 @@ type FilteringRoutingStrategy struct {
BackingStrategy RoutingStrategy
}

func (s *FilteringRoutingStrategy) RouteNextRequest(upstreamsByPriority types.PriorityToUpstreamsMap) (string, error) {
filteredUpstreams := s.filter(upstreamsByPriority)
return s.BackingStrategy.RouteNextRequest(filteredUpstreams)
func (s *FilteringRoutingStrategy) RouteNextRequest(
upstreamsByPriority types.PriorityToUpstreamsMap,
requestMetadata metadata.RequestMetadata,
) (string, error) {
filteredUpstreams := s.filter(upstreamsByPriority, requestMetadata)
return s.BackingStrategy.RouteNextRequest(filteredUpstreams, requestMetadata)
}

func (s *FilteringRoutingStrategy) filter(upstreamsByPriority types.PriorityToUpstreamsMap) types.PriorityToUpstreamsMap {
func (s *FilteringRoutingStrategy) filter(
upstreamsByPriority types.PriorityToUpstreamsMap,
requestMetadata metadata.RequestMetadata,
) types.PriorityToUpstreamsMap {
priorityToHealthyUpstreams := make(types.PriorityToUpstreamsMap)

for priority, upstreamConfigs := range upstreamsByPriority {
Expand All @@ -25,7 +32,7 @@ func (s *FilteringRoutingStrategy) filter(upstreamsByPriority types.PriorityToUp
filteredUpstreams := make([]*config.UpstreamConfig, 0)

for _, upstreamConfig := range upstreamConfigs {
if s.NodeFilter.Apply(nil, upstreamConfig) {
if s.NodeFilter.Apply(requestMetadata, upstreamConfig) {
filteredUpstreams = append(filteredUpstreams, upstreamConfig)
}
}
Expand Down
34 changes: 24 additions & 10 deletions internal/route/node_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,15 @@ import (
"github.com/satsuma-data/node-gateway/internal/metadata"
)

type RequestMetadata struct{}

type NodeFilter interface {
Apply(requestMetadata *RequestMetadata, upstreamConfig *config.UpstreamConfig) bool
Apply(requestMetadata metadata.RequestMetadata, upstreamConfig *config.UpstreamConfig) bool
}

type AndFilter struct {
filters []NodeFilter
}

func (a *AndFilter) Apply(requestMetadata *RequestMetadata, upstreamConfig *config.UpstreamConfig) bool {
func (a *AndFilter) Apply(requestMetadata metadata.RequestMetadata, upstreamConfig *config.UpstreamConfig) bool {
var result = true

for filterIndex := range a.filters {
Expand All @@ -34,7 +32,7 @@ type IsHealthy struct {
healthCheckManager checks.HealthCheckManager
}

func (f *IsHealthy) Apply(_ *RequestMetadata, upstreamConfig *config.UpstreamConfig) bool {
func (f *IsHealthy) Apply(_ metadata.RequestMetadata, upstreamConfig *config.UpstreamConfig) bool {
var upstreamStatus = f.healthCheckManager.GetUpstreamStatus(upstreamConfig.ID)
return upstreamStatus.PeerCheck.IsPassing() && upstreamStatus.SyncingCheck.IsPassing()
}
Expand All @@ -44,7 +42,7 @@ type IsAtGlobalMaxHeight struct {
chainMetadataStore *metadata.ChainMetadataStore
}

func (f *IsAtGlobalMaxHeight) Apply(_ *RequestMetadata, upstreamConfig *config.UpstreamConfig) bool {
func (f *IsAtGlobalMaxHeight) Apply(_ metadata.RequestMetadata, upstreamConfig *config.UpstreamConfig) bool {
maxHeight := f.chainMetadataStore.GetGlobalMaxHeight()

upstreamStatus := f.healthCheckManager.GetUpstreamStatus(upstreamConfig.ID)
Expand All @@ -57,14 +55,27 @@ type IsAtMaxHeightForGroup struct {
chainMetadataStore *metadata.ChainMetadataStore
}

func (f *IsAtMaxHeightForGroup) Apply(_ *RequestMetadata, upstreamConfig *config.UpstreamConfig) bool {
func (f *IsAtMaxHeightForGroup) Apply(_ metadata.RequestMetadata, upstreamConfig *config.UpstreamConfig) bool {
maxHeightForGroup := f.chainMetadataStore.GetMaxHeightForGroup(upstreamConfig.GroupID)

upstreamStatus := f.healthCheckManager.GetUpstreamStatus(upstreamConfig.ID)

return upstreamStatus.BlockHeightCheck.IsPassing(maxHeightForGroup)
}

type SimpleIsStatePresent struct{}

func (f *SimpleIsStatePresent) Apply(
requestMetadata metadata.RequestMetadata,
upstreamConfig *config.UpstreamConfig,
) bool {
if requestMetadata.IsStateRequired {
return upstreamConfig.NodeType == config.Archive
}

return true
}

func CreateNodeFilter(
filterNames []NodeFilterType,
manager checks.HealthCheckManager,
Expand Down Expand Up @@ -96,6 +107,8 @@ func CreateSingleNodeFilter(
healthCheckManager: manager,
chainMetadataStore: store,
}
case SimpleStatePresent:
return &SimpleIsStatePresent{}
default:
panic("Unknown filter type " + filterName + "!")
}
Expand All @@ -104,7 +117,8 @@ func CreateSingleNodeFilter(
type NodeFilterType string

const (
Healthy NodeFilterType = "healthy"
GlobalMaxHeight NodeFilterType = "globalMaxHeight"
MaxHeightForGroup NodeFilterType = "maxHeightForGroup"
Healthy NodeFilterType = "healthy"
GlobalMaxHeight NodeFilterType = "globalMaxHeight"
MaxHeightForGroup NodeFilterType = "maxHeightForGroup"
SimpleStatePresent NodeFilterType = "simpleStatePresent"
)
40 changes: 36 additions & 4 deletions internal/route/node_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import (
"testing"

"github.com/satsuma-data/node-gateway/internal/config"
"github.com/satsuma-data/node-gateway/internal/metadata"
"github.com/stretchr/testify/assert"
)

type AlwaysPass struct{}

func (AlwaysPass) Apply(_ *RequestMetadata, _ *config.UpstreamConfig) bool {
func (AlwaysPass) Apply(metadata.RequestMetadata, *config.UpstreamConfig) bool {
return true
}

type AlwaysFail struct{}

func (AlwaysFail) Apply(_ *RequestMetadata, _ *config.UpstreamConfig) bool {
func (AlwaysFail) Apply(metadata.RequestMetadata, *config.UpstreamConfig) bool {
return false
}

Expand All @@ -24,8 +25,8 @@ func TestAndFilter_Apply(t *testing.T) {
filters []NodeFilter
}

type args struct {
requestMetadata *RequestMetadata
type args struct { //nolint:govet // field alignment doesn't matter in tests
requestMetadata metadata.RequestMetadata
upstreamConfig *config.UpstreamConfig
}

Expand All @@ -50,3 +51,34 @@ func TestAndFilter_Apply(t *testing.T) {
})
}
}

func TestSimpleIsStatePresentFilter_Apply(t *testing.T) {
fullNodeConfig := config.UpstreamConfig{NodeType: config.Full}
archiveNodeConfig := config.UpstreamConfig{NodeType: config.Archive}

stateMethodMetadata := metadata.RequestMetadata{IsStateRequired: true}
nonStateMethodMetadata := metadata.RequestMetadata{IsStateRequired: false}

type args struct { //nolint:govet // field alignment doesn't matter in tests
requestMetadata metadata.RequestMetadata
upstreamConfig *config.UpstreamConfig
}

tests := []struct { //nolint:govet // field alignment doesn't matter in tests
name string
args args
want bool
}{
{"stateMethodFullNode", args{stateMethodMetadata, &fullNodeConfig}, false},
{"stateMethodArchiveNode", args{stateMethodMetadata, &archiveNodeConfig}, true},
{"nonStateMethodFullNode", args{nonStateMethodMetadata, &fullNodeConfig}, true},
{"nonStateMethodArchiveNode", args{nonStateMethodMetadata, &archiveNodeConfig}, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &SimpleIsStatePresent{}
assert.Equalf(t, tt.want, f.Apply(tt.args.requestMetadata, tt.args.upstreamConfig), "Apply(%v, %v)", tt.args.requestMetadata, tt.args.upstreamConfig)
})
}
}
Loading