Skip to content

Comments

feat(network): add dynamic peer count based on active subnets#726

Merged
mergify[bot] merged 12 commits intosigp:release-v1.1.0from
petarjuki7:dynamic_target_peers
Nov 7, 2025
Merged

feat(network): add dynamic peer count based on active subnets#726
mergify[bot] merged 12 commits intosigp:release-v1.1.0from
petarjuki7:dynamic_target_peers

Conversation

@petarjuki7
Copy link
Member

@petarjuki7 petarjuki7 commented Nov 5, 2025

Proposed Changes

Implements dynamic peer target calculation similar to go-ssv:

  • Base: 60 peers
  • Add 3 peers per unique subnet
  • Cap at 150 peers maximum

Additional Info

Breaking Change

  • target_peers config field changed from usize to Option<usize>
  • Previous default: Static 50 peers
  • New default: Dynamic calculation (60-150 based on active subnets)
  • Impact: Users who relied on the implicit default of 50 peers will now get dynamic peer counts starting at 60 base + scaling with subnet duties

Behavior

  • If user provides --target-peers <N>: That static value is always used, regardless of subnet count
  • If user does NOT provide --target-peers: Target peers are calculated dynamically based on active subnets (60 + subnets × 3, capped at 150)

Implementation Notes

The dynamic calculation formula is copied from the go-ssv implementation for compatibility with the existing SSV network. The values are now defined as named constants (BASE_PEER_COUNT, PEERS_PER_SUBNET, MAX_PEER_COUNT) for maintainability.

@claude-code-actions-sigp
Copy link

claude-code-actions-sigp bot commented Nov 5, 2025

Claude finished @petarjuki7's task —— View job


Code Review Complete

Tasks

  • Read modified files to understand changes
  • Analyze implementation logic
  • Check code quality and best practices
  • Review for security and performance issues
  • Verify testing and documentation
  • Provide comprehensive feedback

Summary

This PR implements dynamic peer target calculation based on active subnets. The implementation is straightforward and follows the stated requirements (base 60 + 3 per subnet, capped at 150). However, there are several areas that need attention.

Issues Found

1. Logic Issue: User-provided value override is incomplete

Location: anchor/client/src/lib.rs:491-493

The PR description states: "If the user provides more than the calculated target_peers, then that value is used." However, the implementation only increases target_peers if the user's value is less than the calculated value:

if config.network.target_peers < dyn_peers {
    config.network.target_peers = dyn_peers;
}

This means:

  • If user sets target_peers = 70 and calculation gives 90 → user gets 90 (override works)
  • If user sets target_peers = 160 and calculation gives 90 → user gets 160 (override works)
  • But this behavior contradicts the PR description which says the user value should be used when it's "more than" the calculated value

The current implementation actually means: "Use the maximum of user-provided and calculated values." If this is the intended behavior, the PR description should be corrected. If not, the logic needs adjustment.

2. Missing Documentation

Location: anchor/client/src/lib.rs:481-494

The dynamic peer calculation block lacks any comments explaining:

  • Why this calculation happens at this specific point in the code
  • The rationale for the formula (60 + subnets * 3, capped at 150)
  • The interaction with user-provided configuration

Recommendation: Add a comment block explaining the logic:

// Calculate dynamic peer target based on active subnets.
// Base: 60 peers, add 3 per unique subnet, cap at 150.
// If the user configured a lower target_peers value, override it with the calculated value.

3. Testing Coverage Missing

There are no tests for this new functionality. Consider adding tests for:

  • Calculation with 0 subnets (should give 60)
  • Calculation with subnets that results in < 150 peers
  • Calculation that hits the 150 cap
  • User-provided value higher than calculated value
  • User-provided value lower than calculated value

Note: I did not find existing test files in anchor/client, so integration tests may be in a different location or this may be covered by integration testing elsewhere.

4. Performance: Unnecessary Scoping Block

Location: anchor/client/src/lib.rs:481-494

The code uses an extra scoping block { ... } that doesn't serve a clear purpose:

{
    let state = database.state();
    // ... calculation ...
}

While this isn't harmful, it adds visual noise. The state variable naturally goes out of scope after the block anyway. Consider removing the extra braces unless there's a specific reason for limiting the scope.

5. Minor: Default Value Change Documentation

Location: anchor/network/src/config.rs:103

The default target_peers changed from 50 to 60. This should be mentioned in:

  • The PR description (it only mentions "base 60" without noting this changes the default)
  • Potentially in release notes or migration guide if users rely on the old default

Code Quality Observations

Good:

  • Imports are properly added (std::cmp::min, std::collections::HashSet, database::UniqueIndex)
  • The calculation logic is concise and correct
  • Uses standard library functions appropriately (HashSet for uniqueness)
  • Proper mutability added to config parameter

⚠️ Areas for improvement:

  • Variable naming: dyn_peers could be more descriptive (e.g., calculated_target_peers)
  • The timing of this calculation (after validator store creation but before network initialization) is correct but not obvious from the code

Security & Performance

  • No security issues identified
  • Performance impact is minimal: The calculation iterates over clusters once during startup, which is acceptable
  • The HashSet ensures O(1) insertion for subnet uniqueness

Recommendation

Suggested actions before merging:

  1. ✅ Clarify the user override behavior (either fix the code or fix the PR description)
  2. ✅ Add inline documentation explaining the calculation
  3. ⚠️ Consider adding tests (if appropriate for this codebase's testing strategy)
  4. ⚠️ Remove unnecessary scoping block or add a comment explaining why it's needed
  5. ⚠️ Update PR description to mention the default value change from 50 to 60

Despite these issues, the core implementation is sound and the feature itself is valuable. With the clarifications above, this will be a solid addition.


Copy link
Member

@dknopik dknopik left a comment

Choose a reason for hiding this comment

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

This does not dynamically adjust the target peers if we subscribe or unsubscribe to a subnet while the node is running.

@diegomrsantos
Copy link
Member

If the user provides more than the calculated target_peers, then that value is used.

Shouldn't the value provided by the user be used no matter what?

Comment on lines 481 to 493
{
let state = database.state();
let mut unique_subnets = HashSet::new();
for cluster_id in state.get_own_clusters() {
if let Some(cluster) = state.clusters().get_by(cluster_id) {
let subnet = SubnetId::from_committee(cluster.committee_id(), SUBNET_COUNT);
unique_subnets.insert(subnet);
}
}
let dyn_peers = min(60 + unique_subnets.len() * 3, 150);
if config.network.target_peers < dyn_peers {
config.network.target_peers = dyn_peers;
}

Choose a reason for hiding this comment

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

Clarification needed: The PR description states "If the user provides more than the calculated target_peers, then that value is used." However, this logic only updates target_peers when the user's value is less than the calculated value.

Current behavior: Uses max(user_value, calculated_value)

Is this the intended behavior? If so, please update the PR description. If not, the logic needs adjustment.

Also consider:

  1. Adding a comment explaining this calculation (base 60 + 3 per subnet, capped at 150)
  2. Using a more descriptive variable name than dyn_peers (e.g., calculated_target_peers)
  3. Removing the unnecessary scoping block or adding a comment explaining why it's needed

Copy link
Member Author

Choose a reason for hiding this comment

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

changed now

Copy link
Member

Choose a reason for hiding this comment

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

@claude can we calculate this in the network crate or in another place where the number of subnets is known?

@petarjuki7
Copy link
Member Author

@diegomrsantos @dknopik
I followed the go-ssv code as the reference implementation for now, this is how they do it. I will now update with these changes

@dknopik dknopik added the v1.1.0 label Nov 5, 2025
Copy link
Member

@dknopik dknopik left a comment

Choose a reason for hiding this comment

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

👍

@petarjuki7 petarjuki7 requested a review from dknopik November 5, 2025 14:32
Comment on lines 482 to 494
if dynamic_target_peers {
let state = database.state();
let mut unique_subnets = HashSet::new();
for cluster_id in state.get_own_clusters() {
if let Some(cluster) = state.clusters().get_by(cluster_id) {
let subnet = SubnetId::from_committee(cluster.committee_id(), SUBNET_COUNT);
unique_subnets.insert(subnet);
}
}
let dyn_peers = min(60 + unique_subnets.len() * 3, 150);

config.network.target_peers = Some(dyn_peers);
}
Copy link
Member

Choose a reason for hiding this comment

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

Can we calculate this in the network or in another place where we have the number of subnets?

Copy link
Member Author

Choose a reason for hiding this comment

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

It could probably be done in the network, my logic was to keep it here for all the startup / config code to stay in the client crate. ConnectionManager needs the target_peers in its constructor.

Copy link
Member

Choose a reason for hiding this comment

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

can you e.g. update_dynamic_target_peers on network creation instead of having the duplicated logic here?

Copy link
Member

Choose a reason for hiding this comment

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

Claude gave a great reply #726 (comment)

@diegomrsantos
Copy link
Member

@claude reply to this #726 (comment)

@claude-code-actions-sigp
Copy link

claude-code-actions-sigp bot commented Nov 5, 2025

Claude finished @diegomrsantos's task —— View job


Analyzing Calculation Location

Tasks

  • Read current implementation in client crate
  • Analyze network crate architecture
  • Review ConnectionManager implementation
  • Provide recommendation on calculation location

Fixes workflow not completing tasks and hanging indefinitely.


  1. **Fix Configuration Format**: Updated workflow arguments to use multi-line YAML format (required by the action)
2. **Add Timeout**: Added 15-minute timeout to prevent infinite hangs


Co-Authored-By: diego <diego@sigmaprime.io>
@diegomrsantos
Copy link
Member

@claude reply to this #726 (comment)

@claude-code-actions-sigp
Copy link

claude-code-actions-sigp bot commented Nov 5, 2025

Claude finished @diegomrsantos's task —— View job


Response to Calculation Location Question

Responding to this review comment:


Analysis

After reviewing the codebase, I can see that the dynamic peer calculation is currently done in the client crate (anchor/client/src/lib.rs:481-494), but there's already infrastructure in place to move it to the network crate.

Current State

Good news: The PR already implements dynamic updates in the network crate! Looking at the code:

  1. ConnectionManager has update_dynamic_target_peers() method (anchor/network/src/peer_manager/connection.rs:96-127)
  2. PeerManager calls this method in join_subnet() and leave_subnet() (anchor/network/src/peer_manager/mod.rs:73-98)
  3. The network already dynamically adjusts when subnets change at runtime

The only remaining issue is the initial calculation at startup, which happens in the client crate.

Recommendation: Move Initial Calculation to Network

Yes, the initial calculation should be moved to the network crate. Here's why:

Benefits:

  1. Single Source of Truth: All peer target logic would be in one place (ConnectionManager)
  2. Better Encapsulation: The client crate shouldn't need to know about the peer calculation formula
  3. Consistency: The same logic path would be used at startup and during runtime updates
  4. Cleaner Architecture: Network configuration concerns stay in the network crate

Implementation Approach:

Rather than calculating in client/src/lib.rs, the network crate could:

Option 1: Calculate in ConnectionManager constructor

impl ConnectionManager {
    pub fn new(config: &Config, active_subnets: HashSet<SubnetId>) -> Self {
        let target_peers = config.target_peers.unwrap_or_else(|| {
            // Calculate based on active subnets
            std::cmp::min(60 + active_subnets.len() * 3, 150)
        });
        // ... rest of constructor
    }
}

Option 2: Initialize with default, then update after construction

// In client crate, after creating the network:
if dynamic_target_peers {
    network.update_target_peers_from_subnets(active_subnets);
}

Addressing @petarjuki7's Concern

"my logic was to keep it here for all the startup / config code to stay in the client crate. ConnectionManager needs the target_peers in its constructor."

This is valid, but I'd argue that:

  • ConnectionManager already has logic to update target_peers after construction (line 96-127)
  • The constructor could accept None for target_peers and calculate it internally
  • Or, accept the list of active subnets as a parameter and calculate on the spot

Current State Assessment

The PR is already 90% there - it correctly updates dynamically at runtime. The remaining 10% is just moving the initial calculation from client to network for consistency.


Summary: While the current implementation works, moving the initial calculation to the network crate would improve code organization and maintain a single source of truth for the dynamic peer logic. The network crate already has all the machinery needed; it just needs to handle the initial calculation as well as the runtime updates.

))
.with_max_established_per_peer(Some(1));

self.connection_limits = connection_limits::Behaviour::new(limits);
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be cleaner to update the limits using limits_mut on the existing behaviour

Copy link
Member Author

@petarjuki7 petarjuki7 Nov 6, 2025

Choose a reason for hiding this comment

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

As far as I can tell, limits_mut gives me a &mut ConnectionLimits, but all the methods for updating values (.with_max_established_incoming() etc.) take self (ConnectionLimits). And also the fields of the ConnectionLimits struct are private so I can't just update the fields through &mut ConnectionLimits.

Or did you have in mind to just do something like:

*self.connection_limits.limits_mut() =
 ConnectionLimits::default()
      .with_max_pending_incoming(Some(5))
      .with_max_pending_outgoing(Some(16))
      .with_max_established_incoming(Some(...))

Comment on lines 482 to 494
if dynamic_target_peers {
let state = database.state();
let mut unique_subnets = HashSet::new();
for cluster_id in state.get_own_clusters() {
if let Some(cluster) = state.clusters().get_by(cluster_id) {
let subnet = SubnetId::from_committee(cluster.committee_id(), SUBNET_COUNT);
unique_subnets.insert(subnet);
}
}
let dyn_peers = min(60 + unique_subnets.len() * 3, 150);

config.network.target_peers = Some(dyn_peers);
}
Copy link
Member

Choose a reason for hiding this comment

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

can you e.g. update_dynamic_target_peers on network creation instead of having the duplicated logic here?

Copy link
Member

@jxs jxs left a comment

Choose a reason for hiding this comment

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

Hi Petar, overall looks good to me, left some comments

Comment on lines 481 to 482
let dynamic_target_peers = config.network.target_peers.is_none();
if dynamic_target_peers {
Copy link
Member

Choose a reason for hiding this comment

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

nitpick:

Suggested change
let dynamic_target_peers = config.network.target_peers.is_none();
if dynamic_target_peers {
let target_peers = match config.network.target_peers {
Some(target_peers) => target_peers,
None => {...}

Comment on lines 485 to 491
for cluster_id in state.get_own_clusters() {
if let Some(cluster) = state.clusters().get_by(cluster_id) {
let subnet = SubnetId::from_committee(cluster.committee_id(), SUBNET_COUNT);
unique_subnets.insert(subnet);
}
}
let dyn_peers = min(60 + unique_subnets.len() * 3, 150);
Copy link
Member

Choose a reason for hiding this comment

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

we can simplify this by counting the unique subnets using itertools unique (which we already depend transitively:

Suggested change
for cluster_id in state.get_own_clusters() {
if let Some(cluster) = state.clusters().get_by(cluster_id) {
let subnet = SubnetId::from_committee(cluster.committee_id(), SUBNET_COUNT);
unique_subnets.insert(subnet);
}
}
let dyn_peers = min(60 + unique_subnets.len() * 3, 150);
let unique_subnets = state
.get_own_clusters()
.iter()
.filter_map(|id| state.clusters().get_by(id))
.map(|s| SubnetId::from_committee(s.committee_id(), SUBNET_COUNT))
.unique()
.count();
let dyn_peers = min(60 + unique_subnets.len() * 3, 150);

outcome_rx: mpsc::Receiver<Outcome>,
executor: TaskExecutor,
spec: Arc<ChainSpec>,
dynamic_target_peers: bool,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
dynamic_target_peers: bool,
target_peers: bool,

they can also be static right? If they are user provided

}
let dyn_peers = min(60 + unique_subnets.len() * 3, 150);

config.network.target_peers = Some(dyn_peers);
Copy link
Member

Choose a reason for hiding this comment

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

are we using the value in dyn_peers anywhere else? If not we can avoid declaring config as mut in the input parameters

@petarjuki7 petarjuki7 self-assigned this Nov 6, 2025
This commit refactors the dynamic peer target calculation to improve
code organization, maintainability, and clarity.

Key improvements:
- Extract calculation formula to single method in ConnectionManager
- Remove panic risk by handling Option<usize> gracefully
- Improve variable naming (is_dynamic_target_peers)
- Add comprehensive documentation explaining formula and behavior
- Add tests for calculation formula correctness
- Simplify subnet counting with iterator chain
- Remove unnecessary mutability from config parameter

The calculation logic (base 60 + 3 per subnet, capped at 150) is now
centralized in ConnectionManager::calculate_target_peers(), used for both
startup initialization and runtime updates. When config.target_peers is
None, the calculation is applied automatically. When Some, the user's
value is used as a static override.
Moves calculate_target_peers from ConnectionManager to PeerManager to properly
separate policy (peer targeting strategy) from mechanism (connection management).
PeerManager now calculates the target based on subnet count and passes it to
ConnectionManager via the renamed set_target_peers method.

This improves separation of concerns and eliminates the awkward pattern where
PeerManager had to call ConnectionManager::calculate_target_peers before
instantiating the ConnectionManager.
@diegomrsantos diegomrsantos marked this pull request as draft November 6, 2025 13:13
@diegomrsantos diegomrsantos marked this pull request as ready for review November 6, 2025 13:13
@claude-code-actions-sigp
Copy link

claude-code-actions-sigp bot commented Nov 6, 2025

Claude finished @petarjuki7's task —— View job


Code Review Complete

Tasks

  • Read all changed files to understand implementation
  • Review the PR diff to see actual changes
  • Review code quality and best practices
  • Check for security and performance issues
  • Verify testing coverage
  • Review documentation
  • Analyze outstanding review comments
  • Provide comprehensive feedback

Summary

This PR implements dynamic peer target calculation based on active subnets. The implementation follows the formula: base 60 + 3 per subnet, capped at 150. The recent refactoring (commit 0a0b564) successfully addresses previous review concerns by moving the calculation logic from ConnectionManager to PeerManager, which is a better architectural choice.

Overall Assessment

Strengths:

  • Clean implementation of the dynamic peer calculation formula
  • Proper separation of concerns after the refactoring
  • Good test coverage for the calculation logic
  • Runtime updates work correctly when subnets are added/removed
  • Handles both user-provided static values and dynamic calculation

Issues Found:


1. Missing User Override Documentation

Severity: Minor
Location: PR description and code documentation

The PR description states: "If the user provides more than the calculated target_peers, then that value is used."

However, the actual implementation uses the user's value regardless of whether it's higher or lower than the calculated value:

// anchor/network/src/peer_manager/mod.rs:67-69
let target_peers = config
    .target_peers
    .unwrap_or_else(|| Self::calculate_target_peers(initial_subnet_count));

Recommendation: Update the PR description to accurately reflect the behavior:

If the user provides `target_peers`, that static value is used. Otherwise, the target is calculated dynamically based on active subnets (60 + subnets * 3, capped at 150).

2. Type Change Not Documented

Severity: Minor
Location: anchor/network/src/config.rs:78

The target_peers field changed from usize to Option<usize>, changing the default from 60 to None. This is a subtle breaking change for users who may rely on the field being directly accessible.

Recommendation: Add a note in the PR description under "Additional Info" mentioning this type change and its implications for any code that directly accesses this field.


3. Code Quality: Redundant Variable

Severity: Trivial
Location: anchor/client/src/lib.rs:494

The is_dynamic_target_peers variable could be inlined since it's only used once:

Current:

let is_dynamic_target_peers = config.network.target_peers.is_none();

// Start the p2p network
let mut network = Network::try_new::<E>(
    // ...
    is_dynamic_target_peers,
)

Suggestion:

// Start the p2p network
let mut network = Network::try_new::<E>(
    // ...
    config.network.target_peers.is_none(),
)

Or keep the variable but add a comment explaining its purpose for clarity.


4. Potential for Configuration Confusion

Severity: Minor
Location: Overall design

The behavior where target_peers: None means "dynamic" might not be immediately obvious to users. Consider adding a help message or documentation that explicitly states:

  • --target-peers <N> sets a static peer target (disables dynamic calculation)
  • Omitting --target-peers enables dynamic peer calculation based on active subnets

Code Quality Observations

Good Practices:

  1. Single Responsibility: The refactoring properly separates concerns - PeerManager handles the calculation policy, ConnectionManager handles limits
  2. Test Coverage: Unit tests verify the calculation formula at key points (0, 30, 31, etc.)
  3. Documentation: Functions have clear doc comments explaining parameters and behavior
  4. Error Handling: Proper use of Option types to distinguish between "user-provided" and "calculated" values
  5. Consistency: The formula is consistently applied at startup and during subnet join/leave events

⚠️ Minor Issues:

  1. Magic Numbers: The constants 60, 3, and 150 appear in both code and comments. Consider defining them as named constants at the module level:

    const BASE_PEER_COUNT: usize = 60;
    const PEERS_PER_SUBNET: usize = 3;
    const MAX_PEER_COUNT: usize = 150;
  2. Calculation Location: While the calculation is now in PeerManager, it's still somewhat duplicated across join_subnet and leave_subnet. This is acceptable given the simplicity, but if the formula becomes more complex, consider extracting the "update target if dynamic" logic into a helper method.


Security & Performance

Security:

  • ✅ No security issues identified
  • ✅ Input validation is implicit through the cap at 150 peers
  • ✅ No possibility of unbounded growth

Performance:

  • ✅ Minimal performance impact - calculation is O(1)
  • ✅ Updates happen only when subnets change (not on every heartbeat)
  • ✅ No unnecessary allocations
  • ✅ The initial_subnet_count calculation iterates clusters only once at startup

Testing

Test Coverage: ✅ Good

The PR includes a comprehensive unit test that covers:

  • Base case (0 subnets → 60 peers)
  • Linear growth cases
  • Cap boundary (30 subnets → 150 peers)
  • Above cap cases

Missing Tests:
While the formula itself is well-tested, the integration could benefit from tests for:

  1. User-provided target_peers being respected (no dynamic updates)
  2. Dynamic updates when subnets are added/removed
  3. Initial calculation at startup

However, these may be covered by integration tests elsewhere.


Review of Outstanding Comments

Looking at the review thread:

  1. @dknopik's initial concern about runtime updates - ✅ Resolved by subsequent commits
  2. @dknopik's suggestion to make target_peers an Option - ✅ Implemented
  3. @diegomrsantos's question about calculation location - ✅ Resolved by moving to PeerManager
  4. @jxs's suggestions:
    • Use itertools::unique for subnet counting - Not implemented (current approach with HashSet is fine)
    • Avoid mut config parameter - ✅ Fixed in the refactoring
    • Naming suggestion (is_dynamic_target_peers) - ✅ Implemented

Recommendations

Must Address:

  1. ✅ Update PR description to accurately describe the user override behavior

Should Consider:

  1. ⚠️ Document the type change from usize to Option<usize> in the PR description
  2. ⚠️ Extract magic numbers (60, 3, 150) into named constants for maintainability

Optional:

  1. Consider inlining is_dynamic_target_peers or adding a clarifying comment
  2. Consider adding integration tests for the dynamic behavior (if not already present elsewhere)

Conclusion

This is a well-implemented feature with good architectural decisions. The refactoring to move calculation logic to PeerManager addresses previous review concerns effectively. The main issues are documentation-related rather than code issues.

Recommendation: Approve after minor documentation updates

The code is production-ready. The only required change is updating the PR description to accurately reflect the actual behavior. The other suggestions are minor improvements that can be addressed now or later.


Address code review feedback by:
- Extracting magic numbers (60, 3, 150) into named constants
- Adding clarifying comments about dynamic vs static peer targeting
- Documenting that values are copied from go-ssv for network compatibility
- Updating tests to reference constants instead of hardcoded values

This improves code maintainability and makes the rationale for the calculation
values more explicit.
@diegomrsantos diegomrsantos requested a review from dknopik November 6, 2025 15:13
Comment on lines 480 to 492
// Calculate initial subnet count for dynamic peer target calculation.
// If the user provided a static target_peers value, this count is still used
// by the network layer but the user's value takes precedence.
let initial_subnet_count = {
let state = database.state();
state
.get_own_clusters()
.iter()
.filter_map(|id| state.clusters().get_by(id))
.map(|cluster| SubnetId::from_committee(cluster.committee_id(), SUBNET_COUNT))
.collect::<HashSet<_>>()
.len()
};
Copy link
Member

Choose a reason for hiding this comment

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

is this necessary?

as soon as the first subnet join event comes in, the target will be changed again. So why not start with a target of 60 to avoid adding this here?

Copy link
Member

Choose a reason for hiding this comment

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

true, done

Comment on lines 494 to 498
// Determine if we should dynamically adjust target_peers when subnets change.
// If the user specified a target_peers value (Some), we keep it static throughout runtime.
// If not specified (None), we calculate and adjust target_peers dynamically based on the
// number of active subnets as validators join/leave subnet duties.
let is_dynamic_target_peers = config.network.target_peers.is_none();
Copy link
Member

Choose a reason for hiding this comment

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

we pass the whole network config below. why do we pass this flag additionally seperately?

Copy link
Member

Choose a reason for hiding this comment

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

true, removed

The previous implementation had a logic bug where subnets were counted twice:
1. At startup from DB clusters -> calculated initial target_peers
2. During startup as subnet_service emitted join events -> recalculated repeatedly

This caused target_peers to thrash during startup as subnet joins fired,
incrementing the target repeatedly until all initial subnets were joined.

Solution: Remove initial_subnet_count entirely. When dynamic peer calculation
is enabled, target_peers now:
- Starts at BASE_PEER_COUNT
- Gets updated naturally as subnet_service reports joins/leaves
- Uses subnet_service as single source of truth

Additional cleanup: Moved is_dynamic_target_peers calculation inside
Network::try_new since config is already passed in - no need for the caller
to derive and pass this flag separately.

This is cleaner because:
- Subnet_service is the runtime source of truth, not the DB
- Self-correcting if DB and runtime state diverge
- No state synchronization complexity
- Brief startup adjustment has no real-world impact (peer dialing takes
  much longer than subnet join events)
@diegomrsantos diegomrsantos marked this pull request as draft November 6, 2025 19:07
@diegomrsantos diegomrsantos marked this pull request as ready for review November 6, 2025 19:16
@claude-code-actions-sigp
Copy link

claude-code-actions-sigp bot commented Nov 6, 2025

Claude finished @petarjuki7's task —— View job


Code Review Complete

Tasks

  • Read all changed files to understand implementation
  • Review PR diff against base branch
  • Analyze code quality and best practices
  • Check security and performance
  • Verify testing coverage
  • Review documentation
  • Provide comprehensive feedback

Summary

This PR implements dynamic peer target calculation based on active subnets, following the formula from go-ssv: base 60 + 3 per subnet, capped at 150. The implementation has gone through several iterations addressing previous review feedback, with the most recent refactoring successfully moving calculation logic from ConnectionManager to PeerManager.

Overall Assessment

The implementation is well-structured and production-ready with good architectural decisions. The code quality is high, with proper separation of concerns, clear documentation, and comprehensive test coverage for the calculation logic.


Code Quality

✅ Strengths

  1. Clean Architecture: The refactoring to move calculation logic to PeerManager is the correct architectural choice - PeerManager handles policy, ConnectionManager handles limits
  2. Named Constants: The magic numbers are properly extracted as constants with clear documentation (lines 44-54 in peer_manager/mod.rs)
  3. Test Coverage: Unit tests verify the formula at key boundary points (0, 1, 5, 10, 20, 30, 31, 50, 100 subnets)
  4. Clear Documentation: Function doc comments explain the calculation formula and reference the go-ssv compatibility requirement
  5. Type Safety: Proper use of Option<usize> to distinguish user-provided vs. calculated values
  6. Consistent Application: Dynamic calculation is applied both at startup and during runtime subnet changes

⚠️ Areas for Improvement

1. PR Description Accuracy (Minor)

Issue: The PR description states:

"If the user provides more than the calculated target_peers, then that value is used."

Reality: The user's value is used regardless of whether it's higher or lower than the calculated value:

// anchor/network/src/peer_manager/mod.rs:82
let target_peers = config.target_peers.unwrap_or(BASE_PEER_COUNT);

Recommendation: Update the PR description to accurately reflect the behavior:

**Behavior**
- **If user provides `--target-peers <N>`**: That static value is always used, regardless of subnet count
- **If user does NOT provide `--target-peers`**: Target peers are calculated dynamically based on active subnets (60 + subnets × 3, capped at 150)

2. Breaking Change Documentation (Minor)

Issue: The type change from usize to Option<usize> in anchor/network/src/config.rs:79 is a subtle breaking change:

// Before: pub target_peers: usize,
// After:  pub target_peers: Option<usize>,

Impact: Any code directly accessing config.network.target_peers will need to handle Option instead of a direct value.

Recommendation: Add to PR description under "Additional Info":

### Type Changes
- `Config.target_peers` changed from `usize` to `Option<usize>`
- `None` indicates dynamic calculation, `Some(n)` indicates user-provided static value
- Default behavior changed: previously defaulted to 60, now defaults to `None` (dynamic)

3. Initial Calculation Comments (Trivial)

Location: anchor/network/src/peer_manager/mod.rs:79-82

The initial target_peers calculation could benefit from a clarifying comment:

// Current:
let target_peers = config.target_peers.unwrap_or(BASE_PEER_COUNT);

// Suggested:
// Use user-provided target_peers if specified, otherwise start with base count.
// When dynamic (None), the target will be updated as subnets are joined/left.
let target_peers = config.target_peers.unwrap_or(BASE_PEER_COUNT);

Note: There is actually a comment at lines 79-81, but it comes after the code. Consider moving it before or inlining it.

4. Formula Documentation Location (Trivial)

The formula documentation appears in multiple places:

  • PR description
  • PeerManager::calculate_target_peers doc comment (line 57-61)
  • Constants documentation (lines 45-54)

While this redundancy ensures visibility, the constants documentation says "This value is copied from the go-ssv implementation" three times. Consider consolidating to reduce maintenance burden.


Security & Performance

✅ Security

  • No security issues identified
  • Input validation is implicit through the cap at 150 peers
  • No unbounded growth possible
  • No sensitive data exposure

✅ Performance

  • Minimal performance impact - calculation is O(1)
  • Updates only occur when subnets change (not on every heartbeat)
  • No unnecessary allocations
  • The HashSet for subnet tracking is efficient

Testing

✅ Test Coverage

The PR includes comprehensive unit tests in anchor/network/src/peer_manager/mod.rs:468-505:

Covered scenarios:

  • Base case: 0 subnets → 60 peers ✓
  • Linear growth: 1, 5, 10, 20 subnets ✓
  • Cap boundary: 30 subnets → 150 peers ✓
  • Above cap: 31, 50, 100 subnets → 150 peers ✓

Test quality: Tests verify the formula at strategic points and confirm the cap works correctly.

📝 Potential Additional Tests

While the calculation formula itself is well-tested, the following integration scenarios could benefit from tests (if not covered elsewhere):

  1. User-provided static value is respected (no dynamic updates)
  2. Dynamic updates when join_subnet() is called
  3. Dynamic updates when leave_subnet() is called
  4. Initial calculation matches first join_subnet() result

However, these may be covered by integration tests in other test files.


Documentation

✅ Good Documentation

  1. Function-level: calculate_target_peers has clear doc comments
  2. Constant-level: Each constant (BASE_PEER_COUNT, PEERS_PER_SUBNET, MAX_PEER_COUNT) has documentation
  3. Rationale: Comments explain the go-ssv compatibility requirement

📝 Suggestions

  1. Add a module-level doc comment in peer_manager/mod.rs explaining the dynamic peer calculation feature
  2. Consider adding an example in the doc comment showing the calculation for a specific subnet count

Review of Outstanding Comments

Looking at the review thread, most issues have been addressed:

  1. Dynamic runtime updates - Resolved via join_subnet and leave_subnet methods
  2. Option for target_peers - Implemented
  3. Calculation in network crate - Moved to PeerManager (correct location)
  4. Consistent naming - is_dynamic_target_peers used throughout
  5. Avoid mut config parameter - Fixed by using Option pattern
  6. Extract magic numbers - Done with named constants

Comparison with Previous Review

The previous Claude review (from 2025-11-06) raised several points. Here's how they've been addressed:

  1. "Missing User Override Documentation" - Still needs PR description update (see recommendation Draft a Testing Framework #1 above)
  2. "Type Change Not Documented" - Still needs PR description update (see recommendation Add basic repository structure #2 above)
  3. "Code Quality: Redundant Variable" - Not applicable; is_dynamic_target_peers is passed to Network and used multiple times
  4. "Potential for Configuration Confusion" - Addressed by clear variable naming and documentation
  5. "Magic Numbers" - ✅ RESOLVED - Now properly extracted as named constants

Minor Code Observations

Network.rs (lines 103, 123)

pub is_dynamic_target_peers: bool,

This field is only used to pass to PeerManager methods. This is acceptable, but alternatively you could calculate it on-demand:

// In Network methods:
let is_dynamic = self.swarm.behaviour().peer_manager.is_dynamic();

However, the current approach is cleaner and avoids coupling Network to PeerManager internals.

Client Config (line 186)

config.network.target_peers = cli_args.target_peers;

This correctly passes through Option<usize> from CLI args to network config. Good.


Recommendations Summary

Must Address:

  1. ✅ Update PR description to accurately describe when user override is applied (always, not just when higher)
  2. ✅ Document the breaking type change from usize to Option<usize> in PR description

Should Consider:

  1. ⚠️ Add module-level documentation explaining the dynamic peer calculation feature
  2. ⚠️ Consider consolidating formula documentation to reduce redundancy

Nice to Have:

  1. 📝 Add integration tests for dynamic behavior (if not present elsewhere)
  2. 📝 Add example calculation in doc comments

Conclusion

This is a well-implemented feature with solid architectural decisions. The refactoring to move calculation logic to PeerManager was the right choice. The code is clean, well-tested, and follows Rust best practices.

Recommendation: Approve with minor documentation updates

The code is production-ready. The only required changes are documentation updates to the PR description to accurately reflect the actual behavior. The other suggestions are optional improvements.


Specific Files Review

anchor/client/src/config.rs

  • Clean pass-through of target_peers from CLI to network config
  • No issues

anchor/network/src/config.rs

  • Type change to Option<usize> is correct
  • Default None enables dynamic calculation
  • Well-documented

anchor/network/src/network.rs

  • is_dynamic_target_peers flag properly tracks calculation mode
  • Correctly passed to join_subnet and leave_subnet
  • Clean integration with PeerManager

anchor/network/src/peer_manager/connection.rs

  • set_target_peers method properly updates limits
  • Early return if target unchanged is efficient
  • Connection limit recalculation is correct

anchor/network/src/peer_manager/mod.rs

  • Excellent: Named constants with clear documentation
  • calculate_target_peers is simple and correct
  • Proper integration with join_subnet and leave_subnet
  • Comprehensive unit tests
  • Clear separation of concerns

Copy link
Member

@dknopik dknopik left a comment

Choose a reason for hiding this comment

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

Two nits :)

Can we also adjust the --target-peers help text to describe the behaviour if it is omitted?

@diegomrsantos
Copy link
Member

Two nits :)

Can we also adjust the --target-peers help text to describe the behaviour if it is omitted?

All done

Address review feedback:
- Update CLI help text for --target-peers to explain dynamic calculation when omitted
- Update target_peers field documentation to explain dynamic calculation behavior
- Add debug log in set_target_peers to show old/new values when updating
- Remove unnecessary clippy::too_many_arguments allow attribute
Copy link
Member

@dknopik dknopik left a comment

Choose a reason for hiding this comment

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

LGTM, thanks both!

@mergify mergify bot merged commit 1074c45 into sigp:release-v1.1.0 Nov 7, 2025
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants