Skip to content
60 changes: 58 additions & 2 deletions beacon/preconf/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,29 @@ type PayloadProvider interface {
GetPayloadBySlot(ctx context.Context, slot math.Slot, parentBlockRoot common.Root) (ctypes.BuiltExecutionPayloadEnv, error)
}

// SyncChecker exposes the node's sync status for health checks.
type SyncChecker interface {
// IsAppReady returns nil if the node has committed at least one block.
IsAppReady() error
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: to make it clear from reading this code, we can add a quick comment here explaining:

  • why/how we use the error
  • why its not overriden (bc its part of the comet service already)

// GetSyncData returns the latest committed height and the target height being synced to.
GetSyncData() (latestHeight int64, syncToHeight int64)
}

// ELChecker exposes the execution-layer client's connectivity status.
type ELChecker interface {
// IsConnected returns true if the execution client is reachable.
IsConnected() bool
}

// Server is the preconf API server that serves GetPayload requests from validators.
type Server struct {
logger log.Logger
validatorJWTs ValidatorJWTs
whitelist Whitelist
preconfProposerTracker ProposerTracker
payloadProvider PayloadProvider
syncChecker SyncChecker
elChecker ELChecker
port int

mu sync.RWMutex
Expand All @@ -79,6 +95,8 @@ func NewServer(
whitelist Whitelist,
preconfProposerTracker ProposerTracker,
payloadProvider PayloadProvider,
syncChecker SyncChecker,
elChecker ELChecker,
port int,
) *Server {
return &Server{
Expand All @@ -87,6 +105,8 @@ func NewServer(
whitelist: whitelist,
preconfProposerTracker: preconfProposerTracker,
payloadProvider: payloadProvider,
syncChecker: syncChecker,
elChecker: elChecker,
port: port,
}
}
Expand Down Expand Up @@ -157,13 +177,49 @@ func (s *Server) Stop() error {
return server.Shutdown(ctx)
}

// handleHealth just sends 200 OK to the health check endpoint.
// handleHealth checks sync status and returns 200 when the sequencer is synced
// and ready to produce blocks, or 503 when it is not.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
w.WriteHeader(http.StatusOK)

resp := s.buildHealthResponse()

w.Header().Set("Content-Type", "application/json")
if !resp.IsReady || resp.IsSyncing || !resp.ELConnected {
w.WriteHeader(http.StatusServiceUnavailable)
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.Error("Failed to encode health response", "error", err)
}
}

// buildHealthResponse inspects the node's sync state and EL connectivity
// and produces a HealthResponse.
func (s *Server) buildHealthResponse() *HealthResponse {
resp := &HealthResponse{
IsReady: true,
ELConnected: true,
}

if s.syncChecker != nil {
resp.IsReady = s.syncChecker.IsAppReady() == nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IsAppReady should return a boolean (in general any func thats named Is<something> should return bool) since the error value is never used.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

agree it's a bit weird but this is just the ConsensusService relaying the comet interface. I would avoid to wrap it with an extra layer

latestHeight, syncToHeight := s.syncChecker.GetSyncData()
resp.HeadSlot = latestHeight
resp.SyncDistance = syncToHeight - latestHeight
if resp.SyncDistance < 0 {
resp.SyncDistance = 0
}
resp.IsSyncing = resp.SyncDistance > 0
}

if s.elChecker != nil {
resp.ELConnected = s.elChecker.IsConnected()
}
Comment thread
bar-bera marked this conversation as resolved.

return resp
}

// handleGetPayload handles the GetPayload endpoint.
Expand Down
127 changes: 125 additions & 2 deletions beacon/preconf/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

"github.com/berachain/beacon-kit/beacon/preconf"
"github.com/berachain/beacon-kit/cli/utils/parser"
"github.com/berachain/beacon-kit/errors"

Check failure on line 35 in beacon/preconf/server_test.go

View workflow job for this annotation

GitHub Actions / lint

File is not properly formatted (gci)
ctypes "github.com/berachain/beacon-kit/consensus-types/types"
engineprimitives "github.com/berachain/beacon-kit/engine-primitives/engine-primitives"
"github.com/berachain/beacon-kit/log/noop"
Expand Down Expand Up @@ -120,6 +121,8 @@
newTestWhitelist(t, pubkeyAHex, pubkeyBHex),
tracker,
provider,
&mockSyncChecker{ready: true},
&mockELChecker{connected: true},
0,
)

Expand Down Expand Up @@ -198,6 +201,8 @@
newTestWhitelist(t, pubkeyAHex, pubkeyBHex),
tt.setupTracker(),
&mockPayloadProvider{hasPayload: true},
&mockSyncChecker{ready: true},
&mockELChecker{connected: true},
0,
)

Expand All @@ -220,7 +225,7 @@
func TestServer_RejectsNonPostMethods(t *testing.T) {
t.Parallel()

server := preconf.NewServer(noop.NewLogger[any](), nil, nil, nil, nil, 0)
server := preconf.NewServer(noop.NewLogger[any](), nil, nil, nil, nil, nil, nil, 0)

for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} {
req := httptest.NewRequest(method, preconf.PayloadEndpoint, nil)
Expand Down Expand Up @@ -263,7 +268,7 @@
wl, err := preconf.NewWhitelist(tmpFile)
require.NoError(t, err)

server := preconf.NewServer(noop.NewLogger[any](), nil, wl, nil, nil, 0)
server := preconf.NewServer(noop.NewLogger[any](), nil, wl, nil, nil, nil, nil, 0)

require.True(t, wl.IsWhitelisted(pkA))
require.False(t, wl.IsWhitelisted(pkB))
Expand Down Expand Up @@ -314,3 +319,121 @@
}

func (m *mockPayloadEnvelope) ShouldOverrideBuilder() bool { return false }

// mockSyncChecker implements preconf.SyncChecker for tests.
type mockSyncChecker struct {
ready bool
latestHeight int64
syncToHeight int64
}

func (m *mockSyncChecker) IsAppReady() error {
if !m.ready {
return errors.New("app not ready")
}
return nil
}

func (m *mockSyncChecker) GetSyncData() (int64, int64) {
return m.latestHeight, m.syncToHeight
}

// mockELChecker implements preconf.ELChecker for tests.
type mockELChecker struct {
connected bool
}

func (m *mockELChecker) IsConnected() bool {
return m.connected
}

func TestServer_HealthEndpoint(t *testing.T) {
t.Parallel()

tests := []struct {
name string
syncChecker *mockSyncChecker
elChecker *mockELChecker
wantStatus int
wantReady bool
wantSync bool
wantELConn bool
}{
{
name: "healthy - synced, ready, EL connected",
syncChecker: &mockSyncChecker{ready: true, latestHeight: 100, syncToHeight: 100},
elChecker: &mockELChecker{connected: true},
wantStatus: http.StatusOK,
wantReady: true,
wantSync: false,
wantELConn: true,
},
{
name: "unhealthy - still syncing",
syncChecker: &mockSyncChecker{ready: true, latestHeight: 50, syncToHeight: 100},
elChecker: &mockELChecker{connected: true},
wantStatus: http.StatusServiceUnavailable,
wantReady: true,
wantSync: true,
wantELConn: true,
},
{
name: "unhealthy - app not ready",
syncChecker: &mockSyncChecker{ready: false, latestHeight: 0, syncToHeight: 0},
elChecker: &mockELChecker{connected: true},
wantStatus: http.StatusServiceUnavailable,
wantReady: false,
wantSync: false,
wantELConn: true,
},
{
name: "unhealthy - EL disconnected",
syncChecker: &mockSyncChecker{ready: true, latestHeight: 100, syncToHeight: 100},
elChecker: &mockELChecker{connected: false},
wantStatus: http.StatusServiceUnavailable,
wantReady: true,
wantSync: false,
wantELConn: false,
},
{
name: "healthy - nil checkers",
wantStatus: http.StatusOK,
wantReady: true,
wantSync: false,
wantELConn: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var syncChecker preconf.SyncChecker
if tt.syncChecker != nil {
syncChecker = tt.syncChecker
}
var elChecker preconf.ELChecker
if tt.elChecker != nil {
elChecker = tt.elChecker
}

server := preconf.NewServer(
noop.NewLogger[any](), nil, nil, nil, nil,
syncChecker, elChecker, 0,
)

req := httptest.NewRequest(http.MethodGet, preconf.HealthEndpoint, nil)
rec := httptest.NewRecorder()
server.Handler().ServeHTTP(rec, req)

require.Equal(t, tt.wantStatus, rec.Code)

var resp preconf.HealthResponse
err := json.NewDecoder(rec.Body).Decode(&resp)
require.NoError(t, err)
require.Equal(t, tt.wantReady, resp.IsReady)
require.Equal(t, tt.wantSync, resp.IsSyncing)
require.Equal(t, tt.wantELConn, resp.ELConnected)
})
Comment thread
bar-bera marked this conversation as resolved.
}
}
18 changes: 18 additions & 0 deletions beacon/preconf/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ func NewGetPayloadResponseFromEnvelope(env ctypes.BuiltExecutionPayloadEnv) *Get
}
}

// HealthResponse is the response body for the health endpoint.
type HealthResponse struct {
// IsReady indicates whether the node has committed at least one block.
IsReady bool `json:"is_ready"`

// IsSyncing indicates whether the node is still catching up with the chain.
IsSyncing bool `json:"is_syncing"`

// ELConnected indicates whether the execution-layer client is reachable.
ELConnected bool `json:"el_connected"`

// HeadSlot is the latest committed block height.
HeadSlot int64 `json:"head_slot"`

// SyncDistance is the number of slots remaining until the node is synced.
SyncDistance int64 `json:"sync_distance"`
}

// ErrorResponse is the error response body.
type ErrorResponse struct {
// Code is the error code.
Expand Down
6 changes: 6 additions & 0 deletions node-core/components/preconf_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import (
"github.com/berachain/beacon-kit/beacon/preconf"
"github.com/berachain/beacon-kit/config"
"github.com/berachain/beacon-kit/errors"
"github.com/berachain/beacon-kit/execution/client"
"github.com/berachain/beacon-kit/log/phuslu"
"github.com/berachain/beacon-kit/node-core/types"
payloadbuilder "github.com/berachain/beacon-kit/payload/builder"
)

Expand All @@ -38,6 +40,8 @@ type PreconfServerInput struct {
Whitelist preconf.Whitelist
PreconfProposerTracker preconf.ProposerTracker
LocalBuilder *payloadbuilder.PayloadBuilder
ConsensusService types.ConsensusService
EngineClient *client.EngineClient
}

// ProvidePreconfServer provides the preconf API server for sequencer mode.
Expand Down Expand Up @@ -84,6 +88,8 @@ func ProvidePreconfServer(in PreconfServerInput) (*preconf.Server, error) {
in.Whitelist,
in.PreconfProposerTracker,
in.LocalBuilder,
in.ConsensusService,
in.EngineClient,
cfg.APIPort,
), nil
}
Loading