From 857d0564ee7ff3166be98c55994f7c843b82fded Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:08:25 -0300 Subject: [PATCH] Add preferFallback --- .../service/config/settings-fallback.go | 5 +- .../config/settings-native-fallback.go | 5 +- shared/services/bc-manager.go | 170 +++++---------- shared/services/bc-manager_routing_test.go | 131 ++++++++++++ shared/services/client-order.go | 96 +++++++++ shared/services/client-order_test.go | 196 ++++++++++++++++++ .../services/config/prefer_fallback_test.go | 33 +++ shared/services/config/rocket-pool-config.go | 13 ++ shared/services/ec-manager.go | 82 ++++---- 9 files changed, 568 insertions(+), 163 deletions(-) create mode 100644 shared/services/bc-manager_routing_test.go create mode 100644 shared/services/client-order.go create mode 100644 shared/services/client-order_test.go create mode 100644 shared/services/config/prefer_fallback_test.go diff --git a/rocketpool-cli/service/config/settings-fallback.go b/rocketpool-cli/service/config/settings-fallback.go index 87d6f2568..c3792b455 100644 --- a/rocketpool-cli/service/config/settings-fallback.go +++ b/rocketpool-cli/service/config/settings-fallback.go @@ -15,6 +15,7 @@ type FallbackConfigPage struct { masterConfig *config.RocketPoolConfig useFallbackBox *parameterizedFormItem reconnectDelay *parameterizedFormItem + preferFallback *parameterizedFormItem fallbackNormalItems []*parameterizedFormItem fallbackPrysmItems []*parameterizedFormItem } @@ -56,11 +57,12 @@ func (configPage *FallbackConfigPage) createContent() { // Set up the form items configPage.useFallbackBox = createParameterizedCheckbox(&configPage.masterConfig.UseFallbackClients) configPage.reconnectDelay = createParameterizedStringField(&configPage.masterConfig.ReconnectDelay) + configPage.preferFallback = createParameterizedCheckbox(&configPage.masterConfig.PreferFallback) configPage.fallbackNormalItems = createParameterizedFormItems(configPage.masterConfig.FallbackNormal.GetParameters(), configPage.layout) configPage.fallbackPrysmItems = createParameterizedFormItems(configPage.masterConfig.FallbackPrysm.GetParameters(), configPage.layout) // Map the parameters to the form items in the layout - configPage.layout.mapParameterizedFormItems(configPage.useFallbackBox, configPage.reconnectDelay) + configPage.layout.mapParameterizedFormItems(configPage.useFallbackBox, configPage.reconnectDelay, configPage.preferFallback) configPage.layout.mapParameterizedFormItems(configPage.fallbackNormalItems...) configPage.layout.mapParameterizedFormItems(configPage.fallbackPrysmItems...) @@ -95,6 +97,7 @@ func (configPage *FallbackConfigPage) handleUseFallbackChanged() { default: configPage.layout.addFormItems(configPage.fallbackNormalItems) } + configPage.layout.form.AddFormItem(configPage.preferFallback.item) configPage.layout.refresh() } diff --git a/rocketpool-cli/service/config/settings-native-fallback.go b/rocketpool-cli/service/config/settings-native-fallback.go index d414a0888..0e1ca9268 100644 --- a/rocketpool-cli/service/config/settings-native-fallback.go +++ b/rocketpool-cli/service/config/settings-native-fallback.go @@ -14,6 +14,7 @@ type NativeFallbackConfigPage struct { masterConfig *config.RocketPoolConfig useFallbackBox *parameterizedFormItem reconnectDelay *parameterizedFormItem + preferFallback *parameterizedFormItem fallbackItems []*parameterizedFormItem } @@ -54,10 +55,11 @@ func (configPage *NativeFallbackConfigPage) createContent() { // Set up the form items configPage.useFallbackBox = createParameterizedCheckbox(&configPage.masterConfig.UseFallbackClients) configPage.reconnectDelay = createParameterizedStringField(&configPage.masterConfig.ReconnectDelay) + configPage.preferFallback = createParameterizedCheckbox(&configPage.masterConfig.PreferFallback) configPage.fallbackItems = createParameterizedFormItems(configPage.masterConfig.FallbackNormal.GetParameters(), configPage.layout) // Map the parameters to the form items in the layout - configPage.layout.mapParameterizedFormItems(configPage.useFallbackBox, configPage.reconnectDelay) + configPage.layout.mapParameterizedFormItems(configPage.useFallbackBox, configPage.reconnectDelay, configPage.preferFallback) configPage.layout.mapParameterizedFormItems(configPage.fallbackItems...) // Set up the setting callbacks @@ -84,6 +86,7 @@ func (configPage *NativeFallbackConfigPage) handleUseFallbackChanged() { } configPage.layout.form.AddFormItem(configPage.reconnectDelay.item) configPage.layout.addFormItems(configPage.fallbackItems) + configPage.layout.form.AddFormItem(configPage.preferFallback.item) configPage.layout.refresh() } diff --git a/shared/services/bc-manager.go b/shared/services/bc-manager.go index 91697eb67..8a25a89c6 100644 --- a/shared/services/bc-manager.go +++ b/shared/services/bc-manager.go @@ -27,6 +27,7 @@ type BeaconClientManager struct { primaryReady bool fallbackReady bool ignoreSyncCheck bool + preferFallback bool // static, when non-nil, satisfies every public method of this manager // directly from the provided client instead of dialling a live beacon @@ -80,6 +81,7 @@ func NewBeaconClientManager(cfg *config.RocketPoolConfig) (*BeaconClientManager, // Fallback CC var fallbackProvider string + var preferFallback bool if cfg.UseFallbackClients.Value == true { if cfg.IsNativeMode { fallbackProvider = cfg.FallbackNormal.CcHttpUrl.Value.(string) @@ -91,6 +93,7 @@ func NewBeaconClientManager(cfg *config.RocketPoolConfig) (*BeaconClientManager, fallbackProvider = cfg.FallbackNormal.CcHttpUrl.Value.(string) } } + preferFallback = cfg.PreferFallback.Value == true } var primaryBc beacon.Client @@ -100,12 +103,18 @@ func NewBeaconClientManager(cfg *config.RocketPoolConfig) (*BeaconClientManager, fallbackBc = client.NewStandardHttpClient(fallbackProvider) } + logger := log.NewColorLogger(color.FgHiBlue) + if preferFallback { + logger.Println("Prefer Fallback Clients is enabled; Beacon client requests will use the fallback pair first.") + } + return &BeaconClientManager{ - primaryBc: primaryBc, - fallbackBc: fallbackBc, - logger: log.NewColorLogger(color.FgHiBlue), - primaryReady: true, - fallbackReady: fallbackBc != nil, + primaryBc: primaryBc, + fallbackBc: fallbackBc, + logger: logger, + primaryReady: true, + fallbackReady: fallbackBc != nil, + preferFallback: preferFallback, }, nil } @@ -459,7 +468,22 @@ func checkBcStatus(client beacon.Client) api.ClientStatus { } -// Attempts to run a function progressively through each client until one succeeds or they all fail. +func (m *BeaconClientManager) clientForRole(role clientRole) beacon.Client { + if role == primaryClient { + return m.primaryBc + } + return m.fallbackBc +} + +func (m *BeaconClientManager) logDisconnect(failedName, nextName string, hasNext bool, err error) { + if hasNext { + m.logger.Printlnf("WARNING: %s Beacon client disconnected (%s), using %s...", failedName, err.Error(), nextName) + return + } + m.logger.Printlnf("WARNING: %s Beacon client disconnected (%s)", failedName, err.Error()) +} + +// Attempts to run a function progressively through each client in the preferred order until one succeeds or they all fail. func (m *BeaconClientManager) runFunction0(function bcFunction0) error { // Delegate directly to the static backend when the manager is running in @@ -469,135 +493,47 @@ func (m *BeaconClientManager) runFunction0(function bcFunction0) error { return function(m.static) } - // Check if we can use the primary - if m.primaryReady { - // Try to run the function on the primary - err := function(m.primaryBc) - if err != nil { - if m.isDisconnected(err) { - // If it's disconnected, log it and try the fallback - m.logger.Printlnf("WARNING: Primary Beacon client disconnected (%s), using fallback...", err.Error()) - m.primaryReady = false - return m.runFunction0(function) - } - // If it's a different error, just return it - return err - } - // If there's no error, return the result - return nil - } - - if m.fallbackReady { - // Try to run the function on the fallback - err := function(m.fallbackBc) - if err != nil { - if m.isDisconnected(err) { - // If it's disconnected, log it and try the fallback - m.logger.Printlnf("WARNING: Fallback Beacon client disconnected (%s)", err.Error()) - m.fallbackReady = false - return fmt.Errorf("all Beacon clients failed") - } - - // If it's a different error, just return it - return err - } - // If there's no error, return the result - return nil - } - - return fmt.Errorf("no Beacon clients were ready") + return tryClients(m.preferFallback, &m.primaryReady, &m.fallbackReady, m.isDisconnected, m.logDisconnect, "Beacon", func(role clientRole) error { + return function(m.clientForRole(role)) + }) } -// Attempts to run a function progressively through each client until one succeeds or they all fail. +// Attempts to run a function progressively through each client in the preferred order until one succeeds or they all fail. func (m *BeaconClientManager) runFunction1(function bcFunction1) (interface{}, error) { if m.static != nil { return function(m.static) } - // Check if we can use the primary - if m.primaryReady { - // Try to run the function on the primary - result, err := function(m.primaryBc) - if err != nil { - if m.isDisconnected(err) { - // If it's disconnected, log it and try the fallback - m.logger.Printlnf("WARNING: Primary Beacon client disconnected (%s), using fallback...", err.Error()) - m.primaryReady = false - return m.runFunction1(function) - } - // If it's a different error, just return it - return nil, err - } - // If there's no error, return the result - return result, nil - } - - if m.fallbackReady { - // Try to run the function on the fallback - result, err := function(m.fallbackBc) - if err != nil { - if m.isDisconnected(err) { - // If it's disconnected, log it and try the fallback - m.logger.Printlnf("WARNING: Fallback Beacon client disconnected (%s)", err.Error()) - m.fallbackReady = false - return nil, fmt.Errorf("all Beacon clients failed") - } - // If it's a different error, just return it - return nil, err - } - // If there's no error, return the result - return result, nil + var result interface{} + err := tryClients(m.preferFallback, &m.primaryReady, &m.fallbackReady, m.isDisconnected, m.logDisconnect, "Beacon", func(role clientRole) error { + var callErr error + result, callErr = function(m.clientForRole(role)) + return callErr + }) + if err != nil { + return nil, err } - - return nil, fmt.Errorf("no Beacon clients were ready") - + return result, nil } -// Attempts to run a function progressively through each client until one succeeds or they all fail. +// Attempts to run a function progressively through each client in the preferred order until one succeeds or they all fail. func (m *BeaconClientManager) runFunction2(function bcFunction2) (interface{}, interface{}, error) { if m.static != nil { return function(m.static) } - // Check if we can use the primary - if m.primaryReady { - // Try to run the function on the primary - result1, result2, err := function(m.primaryBc) - if err != nil { - if m.isDisconnected(err) { - // If it's disconnected, log it and try the fallback - m.logger.Printlnf("WARNING: Primary Beacon client disconnected (%s), using fallback...", err.Error()) - m.primaryReady = false - return m.runFunction2(function) - } - // If it's a different error, just return it - return nil, nil, err - } - // If there's no error, return the result - return result1, result2, nil - } - - if m.fallbackReady { - // Try to run the function on the fallback - result1, result2, err := function(m.fallbackBc) - if err != nil { - if m.isDisconnected(err) { - // If it's disconnected, log it and try the fallback - m.logger.Printlnf("WARNING: Fallback Beacon client disconnected (%s)", err.Error()) - m.fallbackReady = false - return nil, nil, fmt.Errorf("all Beacon clients failed") - } - // If it's a different error, just return it - return nil, nil, err - } - // If there's no error, return the result - return result1, result2, nil + var result1, result2 interface{} + err := tryClients(m.preferFallback, &m.primaryReady, &m.fallbackReady, m.isDisconnected, m.logDisconnect, "Beacon", func(role clientRole) error { + var callErr error + result1, result2, callErr = function(m.clientForRole(role)) + return callErr + }) + if err != nil { + return nil, nil, err } - - return nil, nil, fmt.Errorf("no Beacon clients were ready") - + return result1, result2, nil } // Returns true if the error was a connection failure and a backup client is available diff --git a/shared/services/bc-manager_routing_test.go b/shared/services/bc-manager_routing_test.go new file mode 100644 index 000000000..4139960f7 --- /dev/null +++ b/shared/services/bc-manager_routing_test.go @@ -0,0 +1,131 @@ +package services + +import ( + "errors" + "testing" + + "github.com/fatih/color" + + log "github.com/rocket-pool/smartnode/shared/logger" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/beacon/client" +) + +func testLogger() log.ColorLogger { + return log.NewColorLogger(color.FgHiBlue) +} + +func TestBeaconRunFunction1PrefersFallback(t *testing.T) { + t.Parallel() + + primary := client.NewStandardHttpClient("http://primary") + fallback := client.NewStandardHttpClient("http://fallback") + m := &BeaconClientManager{ + primaryBc: primary, + fallbackBc: fallback, + primaryReady: true, + fallbackReady: true, + preferFallback: true, + } + + var used beacon.Client + result, err := m.runFunction1(func(c beacon.Client) (interface{}, error) { + used = c + return "ok", nil + }) + if err != nil { + t.Fatal(err) + } + if result != "ok" { + t.Fatalf("result = %v, want ok", result) + } + if used != fallback { + t.Fatal("expected fallback client to be used first") + } +} + +func TestBeaconRunFunction1FallbackDisconnectUsesPrimary(t *testing.T) { + t.Parallel() + + primary := client.NewStandardHttpClient("http://primary") + fallback := client.NewStandardHttpClient("http://fallback") + m := &BeaconClientManager{ + primaryBc: primary, + fallbackBc: fallback, + primaryReady: true, + fallbackReady: true, + preferFallback: true, + logger: testLogger(), + } + + result, err := m.runFunction1(func(c beacon.Client) (interface{}, error) { + if c == fallback { + return nil, errors.New("dial tcp fallback: connection refused") + } + return "primary", nil + }) + if err != nil { + t.Fatal(err) + } + if result != "primary" { + t.Fatalf("result = %v, want primary", result) + } + if m.fallbackReady { + t.Fatal("fallback should be marked not ready after disconnect") + } + if !m.primaryReady { + t.Fatal("primary should still be ready") + } +} + +func TestBeaconRunFunction1StaticShortCircuits(t *testing.T) { + t.Parallel() + + static := client.NewStandardHttpClient("http://static") + fallback := client.NewStandardHttpClient("http://fallback") + m := NewStaticBeaconClientManager(static) + m.preferFallback = true + m.fallbackReady = true + m.fallbackBc = fallback + + var used beacon.Client + _, err := m.runFunction1(func(c beacon.Client) (interface{}, error) { + used = c + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + if used != static { + t.Fatal("static manager should not route through primary/fallback") + } +} + +func TestExecutionRunFunctionPrefersFallback(t *testing.T) { + t.Parallel() + + primary := &EthClient{} + fallback := &EthClient{} + p := &ExecutionClientManager{ + primaryEc: primary, + fallbackEc: fallback, + primaryReady: true, + fallbackReady: true, + preferFallback: true, + } + + var used *EthClient + result, err := p.runFunction(func(c *EthClient) (interface{}, error) { + used = c + return "ok", nil + }) + if err != nil { + t.Fatal(err) + } + if result != "ok" { + t.Fatalf("result = %v, want ok", result) + } + if used != fallback { + t.Fatal("expected fallback client to be used first") + } +} diff --git a/shared/services/client-order.go b/shared/services/client-order.go new file mode 100644 index 000000000..2bd981a65 --- /dev/null +++ b/shared/services/client-order.go @@ -0,0 +1,96 @@ +package services + +import ( + "fmt" + "strings" +) + +// clientRole identifies which of the two configured clients a manager should +// try next. The order is selected by preferFallback. +type clientRole int + +const ( + primaryClient clientRole = iota + fallbackClient +) + +func clientOrder(preferFallback bool) []clientRole { + if preferFallback { + return []clientRole{fallbackClient, primaryClient} + } + return []clientRole{primaryClient, fallbackClient} +} + +func clientName(role clientRole) string { + switch role { + case primaryClient: + return "Primary" + case fallbackClient: + return "Fallback" + default: + return "Unknown" + } +} + +func isClientReady(role clientRole, primaryReady, fallbackReady bool) bool { + switch role { + case primaryClient: + return primaryReady + case fallbackClient: + return fallbackReady + default: + return false + } +} + +func setClientReady(role clientRole, primaryReady, fallbackReady *bool, ready bool) { + switch role { + case primaryClient: + *primaryReady = ready + case fallbackClient: + *fallbackReady = ready + } +} + +func nextReadyClientName(remaining []clientRole, primaryReady, fallbackReady bool) (string, bool) { + for _, role := range remaining { + if isClientReady(role, primaryReady, fallbackReady) { + return strings.ToLower(clientName(role)), true + } + } + return "", false +} + +// tryClients walks the primary and fallback clients in the configured order. +// call is invoked for each ready client. A disconnect error marks that client +// not-ready and continues to the next; any other error is returned immediately. +// kind is used in the "no clients were ready" message (e.g. "Beacon", "Execution"). +func tryClients( + preferFallback bool, + primaryReady *bool, + fallbackReady *bool, + isDisconnected func(error) bool, + onDisconnect func(failedName, nextName string, hasNext bool, err error), + kind string, + call func(role clientRole) error, +) error { + order := clientOrder(preferFallback) + for i, role := range order { + if !isClientReady(role, *primaryReady, *fallbackReady) { + continue + } + err := call(role) + if err == nil { + return nil + } + if !isDisconnected(err) { + return err + } + setClientReady(role, primaryReady, fallbackReady, false) + nextName, hasNext := nextReadyClientName(order[i+1:], *primaryReady, *fallbackReady) + if onDisconnect != nil { + onDisconnect(clientName(role), nextName, hasNext, err) + } + } + return fmt.Errorf("no %s clients were ready", kind) +} diff --git a/shared/services/client-order_test.go b/shared/services/client-order_test.go new file mode 100644 index 000000000..469cab421 --- /dev/null +++ b/shared/services/client-order_test.go @@ -0,0 +1,196 @@ +package services + +import ( + "errors" + "fmt" + "reflect" + "strings" + "testing" +) + +func TestClientOrder(t *testing.T) { + t.Parallel() + + got := clientOrder(false) + want := []clientRole{primaryClient, fallbackClient} + if !reflect.DeepEqual(got, want) { + t.Fatalf("clientOrder(false) = %v, want %v", got, want) + } + + got = clientOrder(true) + want = []clientRole{fallbackClient, primaryClient} + if !reflect.DeepEqual(got, want) { + t.Fatalf("clientOrder(true) = %v, want %v", got, want) + } +} + +func TestTryClients(t *testing.T) { + t.Parallel() + + disconnectErr := errors.New("dial tcp 127.0.0.1:8545: connect: connection refused") + appErr := errors.New("execution reverted") + isDisconnected := func(err error) bool { + return err != nil && strings.Contains(err.Error(), "dial tcp") + } + + type disconnectEvent struct { + failed string + next string + hasNext bool + } + + tests := []struct { + name string + preferFallback bool + primaryReady bool + fallbackReady bool + primaryErr error + fallbackErr error + wantCalled []clientRole + wantErr string + wantPrimary bool + wantFallback bool + wantDisconnects []disconnectEvent + }{ + { + name: "default both ready uses primary", + primaryReady: true, + fallbackReady: true, + wantCalled: []clientRole{primaryClient}, + wantPrimary: true, + wantFallback: true, + }, + { + name: "default primary disconnect uses fallback", + primaryReady: true, + fallbackReady: true, + primaryErr: disconnectErr, + wantCalled: []clientRole{primaryClient, fallbackClient}, + wantPrimary: false, + wantFallback: true, + wantDisconnects: []disconnectEvent{{failed: "Primary", next: "fallback", hasNext: true}}, + }, + { + name: "prefer both ready uses fallback", + preferFallback: true, + primaryReady: true, + fallbackReady: true, + wantCalled: []clientRole{fallbackClient}, + wantPrimary: true, + wantFallback: true, + }, + { + name: "prefer fallback disconnect uses primary", + preferFallback: true, + primaryReady: true, + fallbackReady: true, + fallbackErr: disconnectErr, + wantCalled: []clientRole{fallbackClient, primaryClient}, + wantPrimary: true, + wantFallback: false, + wantDisconnects: []disconnectEvent{{failed: "Fallback", next: "primary", hasNext: true}}, + }, + { + name: "prefer both disconnect", + preferFallback: true, + primaryReady: true, + fallbackReady: true, + primaryErr: disconnectErr, + fallbackErr: disconnectErr, + wantCalled: []clientRole{fallbackClient, primaryClient}, + wantErr: "no Beacon clients were ready", + wantPrimary: false, + wantFallback: false, + wantDisconnects: []disconnectEvent{ + {failed: "Fallback", next: "primary", hasNext: true}, + {failed: "Primary", hasNext: false}, + }, + }, + { + name: "non-disconnect error does not try the other client", + primaryReady: true, + fallbackReady: true, + primaryErr: appErr, + wantCalled: []clientRole{primaryClient}, + wantErr: appErr.Error(), + wantPrimary: true, + wantFallback: true, + }, + { + name: "prefer non-disconnect error on fallback does not try primary", + preferFallback: true, + primaryReady: true, + fallbackReady: true, + fallbackErr: appErr, + wantCalled: []clientRole{fallbackClient}, + wantErr: appErr.Error(), + wantPrimary: true, + wantFallback: true, + }, + { + name: "no clients ready", + wantErr: "no Beacon clients were ready", + wantPrimary: false, + wantFallback: false, + }, + { + name: "default fallback only", + fallbackReady: true, + wantCalled: []clientRole{fallbackClient}, + wantFallback: true, + }, + { + name: "prefer primary only", + preferFallback: true, + primaryReady: true, + wantCalled: []clientRole{primaryClient}, + wantPrimary: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + primaryReady := tt.primaryReady + fallbackReady := tt.fallbackReady + var called []clientRole + var disconnects []disconnectEvent + + err := tryClients(tt.preferFallback, &primaryReady, &fallbackReady, isDisconnected, func(failedName, nextName string, hasNext bool, _ error) { + disconnects = append(disconnects, disconnectEvent{failed: failedName, next: nextName, hasNext: hasNext}) + }, "Beacon", func(role clientRole) error { + called = append(called, role) + switch role { + case primaryClient: + return tt.primaryErr + case fallbackClient: + return tt.fallbackErr + default: + return fmt.Errorf("unknown role %v", role) + } + }) + + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else if err == nil || err.Error() != tt.wantErr { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + + if !reflect.DeepEqual(called, tt.wantCalled) { + t.Fatalf("called = %v, want %v", called, tt.wantCalled) + } + if primaryReady != tt.wantPrimary { + t.Fatalf("primaryReady = %v, want %v", primaryReady, tt.wantPrimary) + } + if fallbackReady != tt.wantFallback { + t.Fatalf("fallbackReady = %v, want %v", fallbackReady, tt.wantFallback) + } + if !reflect.DeepEqual(disconnects, tt.wantDisconnects) { + t.Fatalf("disconnects = %+v, want %+v", disconnects, tt.wantDisconnects) + } + }) + } +} diff --git a/shared/services/config/prefer_fallback_test.go b/shared/services/config/prefer_fallback_test.go new file mode 100644 index 000000000..f8a99dd75 --- /dev/null +++ b/shared/services/config/prefer_fallback_test.go @@ -0,0 +1,33 @@ +package config + +import "testing" + +func TestPreferFallbackDefaultAndDeserialize(t *testing.T) { + cfg := mustNewRocketPoolConfig(t, "/tmp/rp-test", false) + if cfg.PreferFallback.Value != false { + t.Fatalf("default PreferFallback = %v, want false", cfg.PreferFallback.Value) + } + + serialized := cfg.Serialize() + if got := serialized["root"]["preferFallback"]; got != "false" { + t.Fatalf("serialized preferFallback = %q, want false", got) + } + + delete(serialized["root"], "preferFallback") + loaded := mustNewRocketPoolConfig(t, "/tmp/rp-test", false) + if err := loaded.Deserialize(serialized); err != nil { + t.Fatalf("deserialize without key: %v", err) + } + if loaded.PreferFallback.Value != false { + t.Fatalf("missing key deserialized to %v, want false", loaded.PreferFallback.Value) + } + + serialized["root"]["preferFallback"] = "true" + loaded = mustNewRocketPoolConfig(t, "/tmp/rp-test", false) + if err := loaded.Deserialize(serialized); err != nil { + t.Fatalf("deserialize true: %v", err) + } + if loaded.PreferFallback.Value != true { + t.Fatalf("true deserialized to %v, want true", loaded.PreferFallback.Value) + } +} diff --git a/shared/services/config/rocket-pool-config.go b/shared/services/config/rocket-pool-config.go index 19a02c124..90693c37f 100644 --- a/shared/services/config/rocket-pool-config.go +++ b/shared/services/config/rocket-pool-config.go @@ -76,6 +76,7 @@ type RocketPoolConfig struct { // Fallback settings UseFallbackClients config.Parameter `yaml:"useFallbackClients,omitempty"` ReconnectDelay config.Parameter `yaml:"reconnectDelay,omitempty"` + PreferFallback config.Parameter `yaml:"preferFallback,omitempty"` // Consensus client settings ConsensusClientMode config.Parameter `yaml:"consensusClientMode,omitempty"` @@ -359,6 +360,17 @@ func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConf OverwriteOnUpgrade: false, }, + PreferFallback: config.Parameter{ + ID: "preferFallback", + Name: "Prefer Fallback Clients", + Description: "If enabled, the Smart Node will send API, node, and watchtower requests to your fallback Execution and Consensus clients first, and only use the primary pair if the fallback is unavailable. This keeps Smart Node load off the primary clients so they can focus on validator duties. Your Validator Client is not affected and still uses the primary pair first.", + Type: config.ParameterType_Bool, + Default: map[config.Network]interface{}{config.Network_All: false}, + AffectsContainers: []config.ContainerID{config.ContainerID_Node, config.ContainerID_Watchtower}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + }, + ConsensusClientMode: config.Parameter{ ID: "consensusClientMode", Name: "Consensus Client Mode", @@ -672,6 +684,7 @@ func (cfg *RocketPoolConfig) GetParameters() []*config.Parameter { &cfg.ExecutionClient, &cfg.UseFallbackClients, &cfg.ReconnectDelay, + &cfg.PreferFallback, &cfg.ConsensusClientMode, &cfg.ConsensusClient, &cfg.ExternalConsensusClient, diff --git a/shared/services/ec-manager.go b/shared/services/ec-manager.go index af62e9e62..b029181d5 100644 --- a/shared/services/ec-manager.go +++ b/shared/services/ec-manager.go @@ -32,6 +32,7 @@ type ExecutionClientManager struct { primaryReady bool fallbackReady bool ignoreSyncCheck bool + preferFallback bool // static, when non-nil, satisfies every public method of this manager // directly from the provided client instead of dialling a live EC. @@ -59,6 +60,7 @@ func NewExecutionClientManager(cfg *config.RocketPoolConfig) (*ExecutionClientMa var primaryEcUrl string var fallbackEcUrl string + var preferFallback bool // Get the primary EC url if cfg.IsNativeMode { @@ -82,6 +84,7 @@ func NewExecutionClientManager(cfg *config.RocketPoolConfig) (*ExecutionClientMa fallbackEcUrl = cfg.FallbackNormal.EcHttpUrl.Value.(string) } } + preferFallback = cfg.PreferFallback.Value == true } primaryEc, err := ethclient.Dial(primaryEcUrl) @@ -97,13 +100,19 @@ func NewExecutionClientManager(cfg *config.RocketPoolConfig) (*ExecutionClientMa } } + logger := log.NewColorLogger(color.FgYellow) + if preferFallback { + logger.Println("Prefer Fallback Clients is enabled; Execution client requests will use the fallback pair first.") + } + out := &ExecutionClientManager{ - primaryEcUrl: primaryEcUrl, - fallbackEcUrl: fallbackEcUrl, - primaryEc: &EthClient{primaryEc}, - logger: log.NewColorLogger(color.FgYellow), - primaryReady: true, - fallbackReady: fallbackEc != nil, + primaryEcUrl: primaryEcUrl, + fallbackEcUrl: fallbackEcUrl, + primaryEc: &EthClient{primaryEc}, + logger: logger, + primaryReady: true, + fallbackReady: fallbackEc != nil, + preferFallback: preferFallback, } if fallbackEc != nil { out.fallbackEc = &EthClient{fallbackEc} @@ -595,49 +604,34 @@ func checkEcStatus(client *EthClient) api.ClientStatus { } -// Attempts to run a function progressively through each client until one succeeds or they all fail. -func (p *ExecutionClientManager) runFunction(function ecFunction) (interface{}, error) { - - // Check if we can use the primary - if p.primaryReady { - // Try to run the function on the primary - result, err := function(p.primaryEc) - if err != nil { - if p.isDisconnected(err) { - // If it's disconnected, log it and try the fallback - p.logger.Printlnf("WARNING: Primary Execution client disconnected (%s), using fallback...", err.Error()) - p.primaryReady = false - return p.runFunction(function) - } - - // If it's a different error, just return it - return nil, err - } - - // If there's no error, return the result - return result, nil +func (p *ExecutionClientManager) clientForRole(role clientRole) *EthClient { + if role == primaryClient { + return p.primaryEc } + return p.fallbackEc +} - if p.fallbackReady { - // Try to run the function on the fallback - result, err := function(p.fallbackEc) - if err != nil { - if p.isDisconnected(err) { - // If it's disconnected, log it and try the fallback - p.logger.Printlnf("WARNING: Fallback Execution client disconnected (%s)", err.Error()) - p.fallbackReady = false - return nil, fmt.Errorf("all Execution clients failed") - } +func (p *ExecutionClientManager) logDisconnect(failedName, nextName string, hasNext bool, err error) { + if hasNext { + p.logger.Printlnf("WARNING: %s Execution client disconnected (%s), using %s...", failedName, err.Error(), nextName) + return + } + p.logger.Printlnf("WARNING: %s Execution client disconnected (%s)", failedName, err.Error()) +} - // If it's a different error, just return it - return nil, err - } +// Attempts to run a function progressively through each client in the preferred order until one succeeds or they all fail. +func (p *ExecutionClientManager) runFunction(function ecFunction) (interface{}, error) { - // If there's no error, return the result - return result, nil + var result interface{} + err := tryClients(p.preferFallback, &p.primaryReady, &p.fallbackReady, p.isDisconnected, p.logDisconnect, "Execution", func(role clientRole) error { + var callErr error + result, callErr = function(p.clientForRole(role)) + return callErr + }) + if err != nil { + return nil, err } - - return nil, fmt.Errorf("no Execution clients were ready") + return result, nil } // Returns true if the error was a connection failure and a backup client is available