Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
52 changes: 52 additions & 0 deletions consensus/hotstuff/votecollector/statemachine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,58 @@ func (s *StateMachineTestSuite) TestRegisterVoteConsumer() {
require.Equal(s.T(), expectedVotes, actualVotes)
}

// TestRegisterVoteConsumer_CachingToVerifyingTransition verifies that a vote consumer registered
// while the collector is in the caching state continues to receive votes, in order of arrival,
// across the transition to the verifying state. The proposer's vote is cached synchronously by
// `ProcessBlock` (via `ensureVoteUnique`), so the consumer receives it at a deterministic
// position: between the votes added before and after the transition.
func (s *StateMachineTestSuite) TestRegisterVoteConsumer_CachingToVerifyingTransition() {
votes := 5
proposal := makeSignedProposalWithView(s.view)
block := proposal.Block
processor := s.prepareMockedProcessor(proposal, 1)
proposerVote, err := proposal.ProposerVote()
require.NoError(s.T(), err)

actualVotes := make([]*model.Vote, 0)
s.collector.RegisterVoteConsumer(func(vote *model.Vote) {
actualVotes = append(actualVotes, vote)
})

expectedVotes := make([]*model.Vote, 0)
// Add votes while the collector is in the caching state. Each vote emits `OnVoteProcessed`
// twice: once when cached, and once when replayed into the verifying processor during the
// transition below (consistent with `TestProcessBlock_ProcessingOfCachedVotes`).
for i := 0; i < votes; i++ {
vote := unittest.VoteForBlockFixture(block)
s.notifier.On("OnVoteProcessed", vote).Twice()
processor.On("Process", vote).Return(nil).Once()
require.NoError(s.T(), s.collector.AddVote(vote))
expectedVotes = append(expectedVotes, vote)
}

// `ProcessBlock` caches the proposer's vote (delivering it to the consumer) and transitions
// the collector to the verifying state
require.NoError(s.T(), s.collector.ProcessBlock(proposal))
require.Equal(s.T(), hotstuff.VoteCollectorStatusVerifying, s.collector.Status())
expectedVotes = append(expectedVotes, proposerVote)
Comment on lines +496 to +521

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline consensus/hotstuff/votecollector/statemachine_test.go --items all --match prepareMockedProcessor
rg -n -C 5 'prepareMockedProcessor\(' consensus/hotstuff/votecollector/statemachine_test.go

Repository: onflow/flow-go

Length of output: 9038


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '70,100p' consensus/hotstuff/votecollector/statemachine_test.go
sed -n '220,256p' consensus/hotstuff/votecollector/statemachine_test.go
sed -n '318,331p' consensus/hotstuff/votecollector/statemachine_test.go

python3 - <<'PY'
from pathlib import Path
import re

p = Path('consensus/hotstuff/votecollector/statemachine_test.go')
src = p.read_text()

m = re.search(
    r'func \(s \*StateMachineTestSuite\) prepareMockedProcessor\(proposal \*model\.SignedProposal, expectedProposerVoteProcessings int\) \*mocks\.VerifyingVoteProcessor \{(?P<body>.*?)\n\}',
    src,
    flags=re.S
)
if not m:
    raise SystemExit('prepareMockedProcessor not found')
body = m.group('body')
print(body)
print('--- Process proposer vote occurrences ---')
for name in ['Process', 'OnVoteProcessed']:
    print(name, body.count(name), 'found')
    print(re.findall(r'(processor\.On\("(?:Process|OnVoteProcessed)"[^)]+\))', body))
PY

Repository: onflow/flow-go

Length of output: 5704


Require processing of the proposer vote.

prepareMockedProcessor sets Process(proposerVote) to .Maybe(), while expectedProposerVoteProcessings only controls OnVoteProcessed. This makes the cached proposer-vote replay assertions in tests like TestProcessBlock_ProcessingOfCachedVotes and TestProcessBlock_ByzantineLeaderSpamming_ProposalAfterVote pass without a required Process(proposerVote) expectation. Make the helper also set .Times(expectedProposerVoteProcessings) for Process(proposerVote) when the count is greater than zero.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@consensus/hotstuff/votecollector/statemachine_test.go` around lines 496 -
521, Update prepareMockedProcessor to require Process(proposerVote) exactly
expectedProposerVoteProcessings times when that count is greater than zero,
instead of leaving the expectation optional; preserve the existing optional
behavior when the count is zero so proposer-vote replay is verified in the
affected tests.


// add more votes after the transition; each is processed exactly once
for i := 0; i < votes; i++ {
vote := unittest.VoteForBlockFixture(block)
s.notifier.On("OnVoteProcessed", vote).Once()
processor.On("Process", vote).Return(nil).Once()
require.NoError(s.T(), s.collector.AddVote(vote))
expectedVotes = append(expectedVotes, vote)
}

// The cache invokes consumers synchronously in the goroutine adding the vote, and all votes
// are added on this test's goroutine, so `actualVotes` is complete at this point. The
// asynchronous replay of cached votes into the verifying processor does not re-deliver
// votes to consumers.
require.Equal(s.T(), expectedVotes, actualVotes)
}

func makeSignedProposalWithView(view uint64) *model.SignedProposal {
return helper.MakeSignedProposal(helper.WithProposal(helper.MakeProposal(helper.WithBlock(helper.MakeBlock(helper.WithBlockView(view))))))
}
55 changes: 55 additions & 0 deletions network/p2p/builder/libp2pscaler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,22 @@ func TestApplyResourceLimitOverride(t *testing.T) {
Memory: 9870,
}

// Flow's override config treats every non-positive value as "not overridden". In particular,
// negative values must NOT be passed through to rcmgr, where they carry special meaning
// (-1 = unlimited, -2 = block-all).
transientOverride := p2pconfig.ResourceManagerOverrideLimit{
StreamsInbound: -1, // should not be overridden.
StreamsOutbound: -2, // should not be overridden.
ConnectionsInbound: -1, // should not be overridden.
ConnectionsOutbound: -2, // should not be overridden.
FD: -1, // should not be overridden.
Memory: -2, // should not be overridden.
}

partial := rcmgr.PartialLimitConfig{}
partial.System = ApplyResourceLimitOverride(unittest.Logger(), p2pconfig.ResourceScopeSystem, scaled.ToPartialLimitConfig().System, systemOverride)
partial.PeerDefault = ApplyResourceLimitOverride(unittest.Logger(), p2pconfig.ResourceScopePeer, scaled.ToPartialLimitConfig().PeerDefault, peerOverride)
partial.Transient = ApplyResourceLimitOverride(unittest.Logger(), p2pconfig.ResourceScopeTransient, scaled.ToPartialLimitConfig().Transient, transientOverride)

final := partial.Build(scaled).ToPartialLimitConfig()
require.Equal(t, 456, int(final.System.StreamsOutbound)) // should be overridden.
Expand All @@ -155,6 +168,48 @@ func TestApplyResourceLimitOverride(t *testing.T) {
require.Equal(t, 7890, int(final.System.Memory)) // should be overridden.
require.Equal(t, scaled.ToPartialLimitConfig().System.StreamsInbound, final.System.StreamsInbound) // should NOT be overridden.
require.Equal(t, scaled.ToPartialLimitConfig().System.ConnsOutbound, final.System.ConnsOutbound) // should NOT be overridden.

// none of the negative values may take effect - the entire transient scope keeps its defaults.
require.Equal(t, scaled.ToPartialLimitConfig().Transient, final.Transient)
}

// TestBuildLibp2pResourceManagerLimits_NonPositiveOverridesKeepDefaults tests that overrides with
// a mix of positive, zero, and negative values are merged correctly end-to-end through
// BuildLibp2pResourceManagerLimits: positive values override, non-positive values keep the
// scaled defaults.
func TestBuildLibp2pResourceManagerLimits_NonPositiveOverridesKeepDefaults(t *testing.T) {
cfg, err := config.DefaultConfig()
require.NoError(t, err)

// Build the baseline limits with an explicitly zeroed override (the shipped default config
// contains non-zero overrides, e.g. system streams-inbound), so that the baseline reflects
// the pure scaled defaults that non-positive override values must fall back to.
cfg.NetworkConfig.ResourceManager.Override = p2pconfig.ResourceManagerOverrideScope{}
defaultConcreteLimits, err := BuildLibp2pResourceManagerLimits(unittest.Logger(), &cfg.NetworkConfig.ResourceManager)
require.NoError(t, err)
defaultSystem := defaultConcreteLimits.ToPartialLimitConfig().System

streamsOutboundOverride := int(defaultSystem.StreamsOutbound) + 42
fdOverride := int(defaultSystem.FD) + 42
cfg.NetworkConfig.ResourceManager.Override.System = p2pconfig.ResourceManagerOverrideLimit{
StreamsInbound: -1, // should not be overridden.
StreamsOutbound: streamsOutboundOverride, // should be overridden.
ConnectionsInbound: 0, // should not be overridden.
ConnectionsOutbound: -2, // should not be overridden.
FD: fdOverride, // should be overridden.
Memory: 0, // should not be overridden.
}

overriddenConcreteLimits, err := BuildLibp2pResourceManagerLimits(unittest.Logger(), &cfg.NetworkConfig.ResourceManager)
require.NoError(t, err)

actualSystem := overriddenConcreteLimits.ToPartialLimitConfig().System
require.Equal(t, streamsOutboundOverride, int(actualSystem.StreamsOutbound)) // should be overridden.
require.Equal(t, fdOverride, int(actualSystem.FD)) // should be overridden.
require.Equal(t, defaultSystem.StreamsInbound, actualSystem.StreamsInbound) // should NOT be overridden.
require.Equal(t, defaultSystem.ConnsInbound, actualSystem.ConnsInbound) // should NOT be overridden.
require.Equal(t, defaultSystem.ConnsOutbound, actualSystem.ConnsOutbound) // should NOT be overridden.
require.Equal(t, defaultSystem.Memory, actualSystem.Memory) // should NOT be overridden.
}

// TestBuildLibp2pResourceManagerLimits tests the BuildLibp2pResourceManagerLimits function.
Expand Down
24 changes: 19 additions & 5 deletions network/p2p/inspector/internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,21 @@ func NewRecordCache(config *RecordCacheConfig, recordFactory recordFactory) (*Re
// - exception only in cases of internal data inconsistency or bugs. No errors are expected.
func (r *RecordCache) ReceivedClusterPrefixedMessage(pid peer.ID) (float64, error) {
var err error
var gauge float64
adjustFunc := func(record *ClusterPrefixedMessagesReceivedRecord) *ClusterPrefixedMessagesReceivedRecord {
record, err = r.decayAdjustment(record) // first decay the record
if err != nil {
return record
}
return r.incrementAdjustment(record) // then increment the record
record = r.incrementAdjustment(record) // then increment the record
// Capture the gauge value while the cache holds its lock. Records are mutated in place, so
// reading the gauge from the returned record pointer after AdjustWithInit releases the lock
// would race with concurrent adjustments of the same record.
gauge = record.Gauge
return record
}
nodeID := p2p.MakeId(pid)
adjustedRecord, adjusted := r.c.AdjustWithInit(nodeID, adjustFunc, func() *ClusterPrefixedMessagesReceivedRecord {
_, adjusted := r.c.AdjustWithInit(nodeID, adjustFunc, func() *ClusterPrefixedMessagesReceivedRecord {
return r.recordFactory(nodeID)
})

Expand All @@ -95,7 +101,7 @@ func (r *RecordCache) ReceivedClusterPrefixedMessage(pid peer.ID) (float64, erro
return 0, fmt.Errorf("adjustment failed for peer %s", pid)
}

return adjustedRecord.Gauge, nil
return gauge, nil
}

// GetWithInit returns the current number of cluster prefixed control messages received from a peer.
Expand All @@ -109,13 +115,21 @@ func (r *RecordCache) ReceivedClusterPrefixedMessage(pid peer.ID) (float64, erro
// No errors are expected during normal operation.
func (r *RecordCache) GetWithInit(pid peer.ID) (float64, bool, error) {
var err error
var gauge float64
adjustLogic := func(record *ClusterPrefixedMessagesReceivedRecord) *ClusterPrefixedMessagesReceivedRecord {
// perform decay on gauge value
record, err = r.decayAdjustment(record)
if err != nil {
return record
}
// Capture the gauge value while the cache holds its lock. Records are mutated in place, so
// reading the gauge from the returned record pointer after AdjustWithInit releases the lock
// would race with concurrent adjustments of the same record.
gauge = record.Gauge
return record
}
nodeID := p2p.MakeId(pid)
adjustedRecord, adjusted := r.c.AdjustWithInit(nodeID, adjustLogic, func() *ClusterPrefixedMessagesReceivedRecord {
_, adjusted := r.c.AdjustWithInit(nodeID, adjustLogic, func() *ClusterPrefixedMessagesReceivedRecord {
return r.recordFactory(nodeID)
})
if err != nil {
Expand All @@ -125,7 +139,7 @@ func (r *RecordCache) GetWithInit(pid peer.ID) (float64, bool, error) {
return 0, false, fmt.Errorf("decay adjustment failed for peer %s", pid)
}

return adjustedRecord.Gauge, true, nil
return gauge, true, nil
}

// Remove removes the record of the given peer id from the cache.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,55 @@ func TestNewControlMsgValidationInspector_validateClusterPrefixedTopic(t *testin
cancel()
unittest.RequireCloseBefore(t, inspector.Done(), 5*time.Second, "inspector did not stop")
})

t.Run("validateClusterPrefixedTopic with multiple workers disseminates between 1 and one-per-message notifications past the hard threshold", func(t *testing.T) {
// total number of inspected messages; with a hard threshold of 10, the last 5 exceed it
const total = 15
logCounter := atomic.NewInt64(0)
logger := hookedLogger(logCounter, zerolog.TraceLevel, worker.QueuedItemProcessedLog)
notificationCount := atomic.NewInt64(0)
inspector, signalerCtx, cancel, consumer, rpcTracker, sporkID, idProvider, topicProviderOracle := inspectorFixture(t, func(params *validation.InspectorParams) {
params.Config.ClusterPrefixedMessage.HardThreshold = 10
params.Config.GraftPrune.InvalidTopicIdThreshold = 0
// This test deliberately keeps the default (multi-)worker count and documents the
// concurrency-tolerant contract: the cluster-prefix tracker increment and the subsequent
// hard-threshold check are not atomic, so each of the `total` concurrent inspections may
// observe the tracker above the hard threshold and disseminate a notification. At least
// one notification is guaranteed (the globally last inspection observes all increments),
// and at most one notification per inspected message can be disseminated.
Comment on lines +1472 to +1479

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert the exact threshold-exceeded notification count.

The locked gauge capture gives each inspection its own post-increment gauge. With a threshold of 10 and 15 inspections, only inspections 11 through 15 can notify. The current upper bound of total permits a regression that notifies for messages at or below the threshold.

Proposed fix
-		const total = 15
+		const (
+			total         = 15
+			hardThreshold = 10
+		)
 ...
-			params.Config.ClusterPrefixedMessage.HardThreshold = 10
+			params.Config.ClusterPrefixedMessage.HardThreshold = hardThreshold
 ...
-		count := notificationCount.Load()
-		require.GreaterOrEqual(t, count, int64(1), "at least one inspection past the hard threshold must disseminate a notification")
-		require.LessOrEqual(t, count, int64(total), "at most one notification per inspected message may be disseminated")
+		require.Equal(t, int64(total-hardThreshold), notificationCount.Load())

Also applies to: 1507-1509

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@network/p2p/inspector/validation/control_message_validation_inspector_test.go`
around lines 1472 - 1479, Update the concurrent notification-count assertions in
the test around the cluster-prefix tracker threshold scenario to require exactly
5 notifications for 15 inspections with a hard threshold of 10, rather than
allowing any count up to total. Apply the same exact-count assertion to the
additional assertion near the later referenced block, preserving the existing
concurrency setup.

params.Logger = logger
})
clusterID := flow.ChainID(unittest.IdentifierFixture().String())
clusterPrefixedTopic := channels.Topic(fmt.Sprintf("%s/%s", channels.SyncCluster(clusterID), sporkID)).String()
topicProviderOracle.UpdateTopics([]string{clusterPrefixedTopic})
from := unittest.PeerIdFixture(t)
identity := unittest.IdentityFixture()
idProvider.On("ByPeerID", from).Return(identity, true).Times(total)
checkNotification := checkNotificationFunc(t, from, p2pmsg.CtrlMsgGraft, validation.IsInvalidTopicIDThresholdExceeded, p2p.CtrlMsgTopicTypeClusterPrefixed)
inspectMsgRpc := unittest.P2PRPCFixture(unittest.WithGrafts(unittest.P2PRPCGraftFixture(&clusterPrefixedTopic)))
inspector.ActiveClustersChanged(flow.ChainIDList{flow.ChainID(unittest.IdentifierFixture().String())})
rpcTracker.On("LastHighestIHaveRPCSize").Return(int64(100)).Maybe()
consumer.On("OnInvalidControlMessageNotification", mock.AnythingOfType("*p2p.InvCtrlMsgNotif")).Return(nil).Run(func(args mock.Arguments) {
notificationCount.Inc()
checkNotification(args)
})
inspector.Start(signalerCtx)
unittest.RequireComponentsReadyBefore(t, 1*time.Second, inspector)

for i := 0; i < total; i++ {
require.NoError(t, inspector.Inspect(from, inspectMsgRpc))
}
// wait until all inspections have been processed by the workers
require.Eventually(t, func() bool {
return logCounter.Load() == total
}, time.Second, 100*time.Millisecond)

count := notificationCount.Load()
require.GreaterOrEqual(t, count, int64(1), "at least one inspection past the hard threshold must disseminate a notification")
require.LessOrEqual(t, count, int64(total), "at most one notification per inspected message may be disseminated")
cancel()
unittest.RequireCloseBefore(t, inspector.Done(), 5*time.Second, "inspector did not stop")
})
}

// TestControlMessageValidationInspector_ActiveClustersChanged validates the expected update of the active cluster IDs list.
Expand Down