From e1eb2e8c75c5f26ed2c8deaad86bf7590f3caa98 Mon Sep 17 00:00:00 2001 From: hfuss Date: Sun, 16 Aug 2026 20:22:04 -0400 Subject: [PATCH 1/3] feat(blocklistener): Block height metrics for canonical chain vs node Signed-off-by: hfuss --- cmd/evmconnect.go | 5 + internal/msgs/en_error_messages.go | 1 + mocks/ethblocklistenermocks/block_listener.go | 19 ++ pkg/ethblocklistener/blocklistener.go | 20 ++ pkg/ethblocklistener/blocklistener_metrics.go | 135 +++++++++++ .../blocklistener_metrics_test.go | 220 ++++++++++++++++++ 6 files changed, 400 insertions(+) create mode 100644 pkg/ethblocklistener/blocklistener_metrics.go create mode 100644 pkg/ethblocklistener/blocklistener_metrics_test.go diff --git a/cmd/evmconnect.go b/cmd/evmconnect.go index 4c0fa20..460be39 100644 --- a/cmd/evmconnect.go +++ b/cmd/evmconnect.go @@ -110,6 +110,11 @@ func run() error { return err } + // Emit the block listener metrics into the metrics registry of the manager + if err := c.BlockListener().InitMetrics(ctx, m.MetricsRegistry()); err != nil { + return err + } + // Setup signal handling to cancel the context, which shuts down the API Server signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/internal/msgs/en_error_messages.go b/internal/msgs/en_error_messages.go index 2f0a093..5fc6b8c 100644 --- a/internal/msgs/en_error_messages.go +++ b/internal/msgs/en_error_messages.go @@ -91,4 +91,5 @@ var ( MsgTransactionEstimateTooLargeForBlock = ffe("FF23071", "Gas estimate %s (scaled at %.2f from estimate %s) too large for the current block gas limit %s") MsgMonitoredHeadLengthInvalid = ffe("FF23072", "Monitored head length must be greater than or equal to 1 value=%d") MsgUnknownJSONFormatOptionValue = ffe("FF23073", "Unknown value '%s' for JSON formatting option '%s'. Supported values: %s") + MsgMetricsInitFail = ffe("FF23074", "Failed to initialize metrics for subsystem '%s'") ) diff --git a/mocks/ethblocklistenermocks/block_listener.go b/mocks/ethblocklistenermocks/block_listener.go index 09ce492..cdd8601 100644 --- a/mocks/ethblocklistenermocks/block_listener.go +++ b/mocks/ethblocklistenermocks/block_listener.go @@ -6,6 +6,7 @@ import ( context "context" fftypes "github.com/hyperledger-firefly/common/pkg/fftypes" + metric "github.com/hyperledger-firefly/common/pkg/metric" ethblocklistener "github.com/hyperledger-firefly/evmconnect/pkg/ethblocklistener" ethrpc "github.com/hyperledger-firefly/evmconnect/pkg/ethrpc" ethtypes "github.com/hyperledger-firefly/signer/pkg/ethtypes" @@ -324,6 +325,24 @@ func (_m *BlockListener) GetMonitoredHeadLength() int { return r0 } +// InitMetrics provides a mock function with given fields: ctx, registry +func (_m *BlockListener) InitMetrics(ctx context.Context, registry metric.MetricsRegistry) error { + ret := _m.Called(ctx, registry) + + if len(ret) == 0 { + panic("no return value specified for InitMetrics") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, metric.MetricsRegistry) error); ok { + r0 = rf(ctx, registry) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // ReconcileConfirmationsForTransaction provides a mock function with given fields: ctx, txHash, existingConfirmations, targetConfirmationCount func (_m *BlockListener) ReconcileConfirmationsForTransaction(ctx context.Context, txHash string, existingConfirmations []*ethrpc.MinimalBlockInfo, targetConfirmationCount uint64) (*ethblocklistener.ConfirmationUpdateResult, *ethrpc.TxReceiptJSONRPC, error) { ret := _m.Called(ctx, txHash, existingConfirmations, targetConfirmationCount) diff --git a/pkg/ethblocklistener/blocklistener.go b/pkg/ethblocklistener/blocklistener.go index 93964ea..02bc31c 100644 --- a/pkg/ethblocklistener/blocklistener.go +++ b/pkg/ethblocklistener/blocklistener.go @@ -27,6 +27,7 @@ import ( "github.com/hyperledger-firefly/common/pkg/fftypes" "github.com/hyperledger-firefly/common/pkg/i18n" "github.com/hyperledger-firefly/common/pkg/log" + "github.com/hyperledger-firefly/common/pkg/metric" "github.com/hyperledger-firefly/common/pkg/retry" "github.com/hyperledger-firefly/common/pkg/wsclient" "github.com/hyperledger-firefly/evmconnect/internal/msgs" @@ -89,6 +90,7 @@ type BlockListener interface { WaitClosed() GetBackend() rpcbackend.RPC UTSetBackend(rpcbackend.RPC) + InitMetrics(ctx context.Context, registry metric.MetricsRegistry) error } func toMinimalBlockInfoList(blocks []*ethrpc.BlockInfoJSONRPC) []*ethrpc.MinimalBlockInfo { @@ -142,6 +144,11 @@ type blockListener struct { // headBlockNumber mode: last head value sent on the block listener channel (only written from listenLoop) currentChainHead uint64 + + // metrics are optional - only emitted once InitMetrics has been called + metricsLock sync.RWMutex + metrics metric.MetricsManager + metricsLoopDone chan struct{} } func NewBlockListener(ctx context.Context, retry *retry.Retry, conf *BlockListenerConfig, httpConf *ffresty.Config, wsConf *wsclient.WSConfig) (bl BlockListener, err error) { @@ -406,11 +413,15 @@ func (bl *blockListener) listenLoop() { failCount++ continue } + // In light mode there is no canonical chain being built, so the head we dispatch to + // consumers is what we report as the canonical height (the metrics loop separately + // reports the target height) if head == bl.currentChainHead { failCount = 0 continue } bl.currentChainHead = head + bl.setBlockHeightMetric(metricCanonicalBlockHeight, bl.currentChainHead) update := &ffcapi.BlockHashEvent{GapPotential: false, Created: fftypes.Now(), HeadBlockNumber: bl.currentChainHead} bl.consumerMux.Lock() consumers := make([]*BlockUpdateConsumer, 0, len(bl.consumers)) @@ -826,4 +837,13 @@ func (bl *blockListener) WaitClosed() { case <-bl.ctx.Done(): } } + bl.metricsLock.RLock() + metricsLoopDone := bl.metricsLoopDone + bl.metricsLock.RUnlock() + if metricsLoopDone != nil { + select { + case <-metricsLoopDone: + case <-bl.ctx.Done(): + } + } } diff --git a/pkg/ethblocklistener/blocklistener_metrics.go b/pkg/ethblocklistener/blocklistener_metrics.go new file mode 100644 index 0000000..b761089 --- /dev/null +++ b/pkg/ethblocklistener/blocklistener_metrics.go @@ -0,0 +1,135 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ethblocklistener + +import ( + "context" + "time" + + "github.com/hyperledger-firefly/common/pkg/i18n" + "github.com/hyperledger-firefly/common/pkg/log" + "github.com/hyperledger-firefly/common/pkg/metric" + "github.com/hyperledger-firefly/evmconnect/internal/msgs" + "github.com/hyperledger-firefly/evmconnect/pkg/ethrpc" + "github.com/hyperledger-firefly/transaction-manager/pkg/ffcapi" +) + +const ( + metricsSubsystem = "blocklistener" + + // metricTargetBlockHeight is the block height the node we are connected to reports via eth_blockNumber. + // If this is not what we expect for the chain, the node/URL we are talking to is unhealthy. + metricTargetBlockHeight = "target_block_height" + // metricCanonicalBlockHeight is the height of the head of the canonical chain this listener is managing, + // built from the block filter / newHeads subscription. It should track the target height very closely - + // a sustained gap means the listener (or the filter behind it) is not keeping up. + metricCanonicalBlockHeight = "canonical_block_height" +) + +// InitMetrics registers the block height gauges against the supplied registry, and starts the poll loop +// that emits them. Until this is called no metrics are emitted, and no polling is performed. +func (bl *blockListener) InitMetrics(ctx context.Context, registry metric.MetricsRegistry) error { + mm, err := registry.NewMetricsManagerForSubsystem(ctx, metricsSubsystem) + if err != nil { + return i18n.WrapError(ctx, err, msgs.MsgMetricsInitFail, metricsSubsystem) + } + mm.NewGaugeMetric(ctx, metricTargetBlockHeight, "The block height reported by the connected node via eth_blockNumber", false) + mm.NewGaugeMetric(ctx, metricCanonicalBlockHeight, "The block height of the head of the canonical chain tracked by the block listener", false) + + bl.metricsLock.Lock() + defer bl.metricsLock.Unlock() + bl.metrics = mm + if bl.metricsLoopDone == nil { + bl.metricsLoopDone = make(chan struct{}) + go bl.metricsLoop() + } + return nil +} + +func (bl *blockListener) getMetrics() metric.MetricsManager { + bl.metricsLock.RLock() + defer bl.metricsLock.RUnlock() + return bl.metrics +} + +func (bl *blockListener) setBlockHeightMetric(metricName string, blockHeight uint64) { + mm := bl.getMetrics() + if mm == nil { + return + } + mm.SetGaugeMetric(bl.ctx, metricName, float64(blockHeight), nil) +} + +// metricsLoop samples the block heights on the block polling interval, independently of the listen loop. +// Deliberately decoupled, so the target height keeps being reported while the listen loop is failing and +// backing off - that is exactly when the gap between the two heights is the signal you want. +func (bl *blockListener) metricsLoop() { + defer close(bl.metricsLoopDone) + + // Wait for the listen loop to establish the initial block height before making any query of our own. + // As well as avoiding driving JSON/RPC traffic before the listener is running, this ensures the + // WebSocket backend (when configured) has been switched in before we read bl.backend. + select { + case <-bl.initialBlockHeightObtained: + case <-bl.ctx.Done(): + return + } + + for { + bl.emitChainStateMetrics() + select { + case <-bl.ctx.Done(): + return + case <-time.After(bl.BlockPollingInterval): + } + } +} + +func (bl *blockListener) emitChainStateMetrics() { + if bl.getMetrics() == nil { + return // never drive any query of the node when metrics are not enabled + } + + // The canonical head is free to read - note in light chain tracking mode there is no canonical chain, + // so the listen loop emits the head it dispatches to consumers instead + if bl.ChainTrackingMode != ffcapi.ChainTrackingModeLight { + if canonicalHeight, ok := bl.getCanonicalBlockHeight(); ok { + bl.setBlockHeightMetric(metricCanonicalBlockHeight, canonicalHeight) + } + } + + // The target height requires a query of the node + head, err := bl.refreshHighestBlockFromRPC() + if err != nil { + // Purely a metrics query - the listen loop has its own error handling for the chain state + log.L(bl.ctx).Warnf("Failed to query target block height for metrics: %s", err) + return + } + bl.setBlockHeightMetric(metricTargetBlockHeight, head) +} + +// getCanonicalBlockHeight returns the head of the in-memory canonical chain, with ok false until we +// have indexed a block. +func (bl *blockListener) getCanonicalBlockHeight() (uint64, bool) { + bl.canonicalChainLock.RLock() + defer bl.canonicalChainLock.RUnlock() + back := bl.canonicalChain.Back() + if back == nil || back.Value == nil { + return 0, false + } + return back.Value.(*ethrpc.BlockInfoJSONRPC).Number.Uint64(), true +} diff --git a/pkg/ethblocklistener/blocklistener_metrics_test.go b/pkg/ethblocklistener/blocklistener_metrics_test.go new file mode 100644 index 0000000..749dbce --- /dev/null +++ b/pkg/ethblocklistener/blocklistener_metrics_test.go @@ -0,0 +1,220 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ethblocklistener + +import ( + "context" + "testing" + "time" + + "github.com/hyperledger-firefly/common/pkg/fftypes" + "github.com/hyperledger-firefly/common/pkg/metric" + "github.com/hyperledger-firefly/evmconnect/mocks/rpcbackendmocks" + "github.com/hyperledger-firefly/evmconnect/pkg/ethrpc" + "github.com/hyperledger-firefly/signer/pkg/ethtypes" + "github.com/hyperledger-firefly/signer/pkg/rpcbackend" + "github.com/hyperledger-firefly/transaction-manager/pkg/ffcapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// readGaugeMetric returns the current value of one of our block height gauges, and whether it has +// been reported at all yet +func readGaugeMetric(t *testing.T, registry metric.MetricsRegistry, metricName string) (float64, bool) { + mfs, err := registry.GetGatherer().Gather() + require.NoError(t, err) + fullName := "ff_" + metricsSubsystem + "_" + metricName + for _, mf := range mfs { + if mf.GetName() == fullName { + require.Len(t, mf.GetMetric(), 1) + return mf.GetMetric()[0].GetGauge().GetValue(), true + } + } + return 0, false +} + +func waitForGaugeMetric(t *testing.T, registry metric.MetricsRegistry, metricName string, expected float64) { + assert.Eventually(t, func() bool { + v, ok := readGaugeMetric(t, registry, metricName) + return ok && v == expected + }, 5*time.Second, time.Millisecond, "gauge %s did not reach %f", metricName, expected) +} + +func initTestMetrics(t *testing.T, bl *blockListener) metric.MetricsRegistry { + registry := metric.NewPrometheusMetricsRegistry("ut") + require.NoError(t, bl.InitMetrics(context.Background(), registry)) + return registry +} + +func TestBlockListenerMetricsInitFailDuplicateSubsystem(t *testing.T) { + _, bl, _, done := newTestBlockListener(t) + defer done() + + registry := metric.NewPrometheusMetricsRegistry("ut") + _, err := registry.NewMetricsManagerForSubsystem(context.Background(), metricsSubsystem) + require.NoError(t, err) + + err = bl.InitMetrics(context.Background(), registry) + assert.Regexp(t, "FF23074", err) + assert.Nil(t, bl.getMetrics()) + assert.Nil(t, bl.metricsLoopDone) // no poll loop started +} + +func TestBlockListenerMetricsNoopBeforeInit(t *testing.T) { + _, bl, _, done := newTestBlockListener(t) + defer done() + + bl.canonicalChain.PushBack(ðrpc.BlockInfoJSONRPC{ + Number: ethtypes.HexUint64(1000), + Hash: testBlockHashFor(1000), + }) + + // No metrics, and no eth_blockNumber call for the target height - the latter asserted by done() + bl.setBlockHeightMetric(metricTargetBlockHeight, 1000) + bl.emitChainStateMetrics() +} + +func TestBlockListenerMetricsChainStateHeights(t *testing.T) { + _, bl, mRPC, done := newTestBlockListener(t) + defer done() + + registry := initTestMetrics(t, bl) + mRPC.On("CallRPC", mock.Anything, mock.Anything, "eth_blockNumber").Return(nil).Run(func(args mock.Arguments) { + *args[1].(*ethtypes.HexInteger) = *ethtypes.NewHexIntegerU64(1005) + }) + + // The target height is reported before any block has been indexed + bl.emitChainStateMetrics() + _, ok := readGaugeMetric(t, registry, metricCanonicalBlockHeight) + assert.False(t, ok) + v, ok := readGaugeMetric(t, registry, metricTargetBlockHeight) + assert.True(t, ok) + assert.Equal(t, float64(1005), v) + + // Once blocks are indexed the canonical height is reported too + bl.canonicalChain.PushBack(ðrpc.BlockInfoJSONRPC{ + Number: ethtypes.HexUint64(1000), + Hash: testBlockHashFor(1000), + }) + bl.canonicalChain.PushBack(ðrpc.BlockInfoJSONRPC{ + Number: ethtypes.HexUint64(1001), + Hash: testBlockHashFor(1001), + }) + bl.emitChainStateMetrics() + v, ok = readGaugeMetric(t, registry, metricCanonicalBlockHeight) + assert.True(t, ok) + assert.Equal(t, float64(1001), v) + + // The canonical height follows the chain back down when a re-org trims the head + _ = bl.canonicalChain.Remove(bl.canonicalChain.Back()) + bl.emitChainStateMetrics() + v, _ = readGaugeMetric(t, registry, metricCanonicalBlockHeight) + assert.Equal(t, float64(1000), v) +} + +func TestBlockListenerMetricsTargetBlockHeightQueryFail(t *testing.T) { + _, bl, mRPC, done := newTestBlockListener(t) + defer done() + + registry := initTestMetrics(t, bl) + mRPC.On("CallRPC", mock.Anything, mock.Anything, "eth_blockNumber").Return(&rpcbackend.RPCError{Message: "pop"}) + + // No retry, and nothing reported - the listen loop drives the chain state error handling + bl.emitChainStateMetrics() + _, ok := readGaugeMetric(t, registry, metricTargetBlockHeight) + assert.False(t, ok) +} + +func TestBlockListenerMetricsLoopWaitsForInitialBlockHeight(t *testing.T) { + _, bl, _, done := newTestBlockListener(t) + defer done() + + // The listen loop is never started, so the metrics loop must make no query at all (asserted by done()) + initTestMetrics(t, bl) + time.Sleep(shortDelay) + require.NotNil(t, bl.metricsLoopDone) +} + +func TestBlockListenerMetricsInitTwiceStartsOneLoop(t *testing.T) { + _, bl, _, done := newTestBlockListener(t) + defer done() + + initTestMetrics(t, bl) + loopDone := bl.metricsLoopDone + initTestMetrics(t, bl) // separate registry, so registration succeeds again + assert.Equal(t, loopDone, bl.metricsLoopDone) +} + +func TestBlockListenerMetricsFullMode(t *testing.T) { + blockHash1000 := testBlockHashFor(1000) + blockHash1001 := testBlockHashFor(1001) + + ctx, bl, _, done := newTestBlockListener(t, func(conf *BlockListenerConfig, mRPC *rpcbackendmocks.Backend, _ context.CancelFunc) { + conf.BlockPollingInterval = 1 * time.Millisecond + + mRPC.On("CallRPC", mock.Anything, mock.Anything, "eth_blockNumber").Return(nil).Run(func(args mock.Arguments) { + *args[1].(*ethtypes.HexInteger) = *ethtypes.NewHexIntegerU64(1001) + }) + mockSeedBlockNotFound(mRPC, 1001-uint64(conf.MonitoredHeadLength)+1) + mockNewBlockFilter(mRPC, testBlockFilterID1) + mockFilterChanges(mRPC, testBlockFilterID1, nil, blockHash1001).Once() + mockFilterChangesEmpty(mRPC) + mockBlockByHash(mRPC, 1001, blockHash1001, blockHash1000) + }) + defer done() + + registry := initTestMetrics(t, bl) + + updates := make(chan *ffcapi.BlockHashEvent, 16) + bl.AddConsumer(ctx, &BlockUpdateConsumer{ + ID: fftypes.NewUUID(), + Ctx: ctx, + Updates: updates, + }) + + // The height the node reports, and the height of the chain we've built from the filter + waitForGaugeMetric(t, registry, metricTargetBlockHeight, 1001) + waitForGaugeMetric(t, registry, metricCanonicalBlockHeight, 1001) +} + +func TestBlockListenerMetricsLightMode(t *testing.T) { + ctx, bl, _, done := newTestBlockListener(t, func(conf *BlockListenerConfig, mRPC *rpcbackendmocks.Backend, _ context.CancelFunc) { + conf.ChainTrackingMode = ffcapi.ChainTrackingModeLight + conf.BlockPollingInterval = 1 * time.Millisecond + + mRPC.On("CallRPC", mock.Anything, mock.Anything, "eth_blockNumber").Return(nil).Run(func(args mock.Arguments) { + *args[1].(*ethtypes.HexInteger) = *ethtypes.NewHexIntegerU64(2000) + }) + mockNewBlockFilter(mRPC, testBlockFilterID1) + mockFilterChangesEmpty(mRPC) + }) + defer done() + + registry := initTestMetrics(t, bl) + + updates := make(chan *ffcapi.BlockHashEvent, 16) + bl.AddConsumer(ctx, &BlockUpdateConsumer{ + ID: fftypes.NewUUID(), + Ctx: ctx, + Updates: updates, + }) + + // In light mode there is no canonical chain, so the head we dispatch is the canonical height + waitForGaugeMetric(t, registry, metricTargetBlockHeight, 2000) + waitForGaugeMetric(t, registry, metricCanonicalBlockHeight, 2000) +} From 6384c74eb77700bfd60d86824fc5960df4f96558 Mon Sep 17 00:00:00 2001 From: hfuss Date: Sun, 16 Aug 2026 20:30:42 -0400 Subject: [PATCH 2/3] cleanup comments Signed-off-by: hfuss --- pkg/ethblocklistener/blocklistener.go | 3 +-- pkg/ethblocklistener/blocklistener_metrics.go | 12 ++++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/pkg/ethblocklistener/blocklistener.go b/pkg/ethblocklistener/blocklistener.go index 02bc31c..dbb658b 100644 --- a/pkg/ethblocklistener/blocklistener.go +++ b/pkg/ethblocklistener/blocklistener.go @@ -414,8 +414,7 @@ func (bl *blockListener) listenLoop() { continue } // In light mode there is no canonical chain being built, so the head we dispatch to - // consumers is what we report as the canonical height (the metrics loop separately - // reports the target height) + // consumers is what we report as the canonical height if head == bl.currentChainHead { failCount = 0 continue diff --git a/pkg/ethblocklistener/blocklistener_metrics.go b/pkg/ethblocklistener/blocklistener_metrics.go index b761089..2b5bbcc 100644 --- a/pkg/ethblocklistener/blocklistener_metrics.go +++ b/pkg/ethblocklistener/blocklistener_metrics.go @@ -31,17 +31,15 @@ import ( const ( metricsSubsystem = "blocklistener" - // metricTargetBlockHeight is the block height the node we are connected to reports via eth_blockNumber. - // If this is not what we expect for the chain, the node/URL we are talking to is unhealthy. + // metricTargetBlockHeight is the block height the endpoint we are connected to reports via eth_blockNumber. metricTargetBlockHeight = "target_block_height" // metricCanonicalBlockHeight is the height of the head of the canonical chain this listener is managing, - // built from the block filter / newHeads subscription. It should track the target height very closely - - // a sustained gap means the listener (or the filter behind it) is not keeping up. + // built from the block filter / newHeads subscription. It should track the target height very closely. metricCanonicalBlockHeight = "canonical_block_height" ) // InitMetrics registers the block height gauges against the supplied registry, and starts the poll loop -// that emits them. Until this is called no metrics are emitted, and no polling is performed. +// that emits them. func (bl *blockListener) InitMetrics(ctx context.Context, registry metric.MetricsRegistry) error { mm, err := registry.NewMetricsManagerForSubsystem(ctx, metricsSubsystem) if err != nil { @@ -74,9 +72,7 @@ func (bl *blockListener) setBlockHeightMetric(metricName string, blockHeight uin mm.SetGaugeMetric(bl.ctx, metricName, float64(blockHeight), nil) } -// metricsLoop samples the block heights on the block polling interval, independently of the listen loop. -// Deliberately decoupled, so the target height keeps being reported while the listen loop is failing and -// backing off - that is exactly when the gap between the two heights is the signal you want. +// metricsLoop samples the block heights on the block polling interval. Decoupled from the listener loop. func (bl *blockListener) metricsLoop() { defer close(bl.metricsLoopDone) From fc14bb107eba9cdd0818f540fef11f0337584618 Mon Sep 17 00:00:00 2001 From: hfuss Date: Mon, 17 Aug 2026 08:52:21 -0400 Subject: [PATCH 3/3] rename funcs for clarity Signed-off-by: hfuss --- pkg/ethblocklistener/blocklistener.go | 6 +++--- pkg/ethblocklistener/blocklistener_metrics.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/ethblocklistener/blocklistener.go b/pkg/ethblocklistener/blocklistener.go index dbb658b..0cfb864 100644 --- a/pkg/ethblocklistener/blocklistener.go +++ b/pkg/ethblocklistener/blocklistener.go @@ -314,8 +314,8 @@ func (bl *blockListener) establishBlockHeightWithRetry() error { }) } -// refreshHighestBlockFromRPC updates highestBlock from eth_blockNumber. Caller must not hold canonicalChainLock. -func (bl *blockListener) refreshHighestBlockFromRPC() (uint64, error) { +// queryBlockHeightFromRPC queries eth_blockNumber and returns the result, without updating any listener state. +func (bl *blockListener) queryBlockHeightFromRPC() (uint64, error) { var hexBlockHeight ethtypes.HexInteger rpcErr := bl.backend.CallRPC(bl.ctx, &hexBlockHeight, "eth_blockNumber") if rpcErr != nil { @@ -407,7 +407,7 @@ func (bl *blockListener) listenLoop() { } if bl.ChainTrackingMode == ffcapi.ChainTrackingModeLight { - head, err := bl.refreshHighestBlockFromRPC() + head, err := bl.queryBlockHeightFromRPC() if err != nil { log.L(bl.ctx).Errorf("Failed to refresh chain head: %s", err) failCount++ diff --git a/pkg/ethblocklistener/blocklistener_metrics.go b/pkg/ethblocklistener/blocklistener_metrics.go index 2b5bbcc..a0e7199 100644 --- a/pkg/ethblocklistener/blocklistener_metrics.go +++ b/pkg/ethblocklistener/blocklistener_metrics.go @@ -108,8 +108,8 @@ func (bl *blockListener) emitChainStateMetrics() { } } - // The target height requires a query of the node - head, err := bl.refreshHighestBlockFromRPC() + // The target height requires a query of the node - a pure read, no listener state is updated + head, err := bl.queryBlockHeightFromRPC() if err != nil { // Purely a metrics query - the listen loop has its own error handling for the chain state log.L(bl.ctx).Warnf("Failed to query target block height for metrics: %s", err)