Skip to content

feat: introduce p2p networking stack#420

Closed
scarmuega wants to merge 1 commit into
mainfrom
scarmuega/p2p-integration
Closed

feat: introduce p2p networking stack#420
scarmuega wants to merge 1 commit into
mainfrom
scarmuega/p2p-integration

Conversation

@scarmuega

Copy link
Copy Markdown
Contributor

⚠️ This is WIP

This pull request introduces the new P2P network machinery from pallas. The new network stack lives in the pallas-network2 crate and is independent from the previous network code, meaning, they can co-exist side-by-side in the same crate.

I won't go into details here of how the internals of the p2p machinery work, but I'll explain the specific integration strategy:

  • The new "pull" stage (pull_p2p.rs) now represents an abstraction for pulling data from a whole network of peers instead of a single upstream peer like before. This stage owns the whole network interface and is responsible of mediating between downstream stages and the external peers. Please note that there are no background threads / stack introduced by the network stack, the "tick" of the stage is what progresses the state of the network (by calling network.poll_next()).
  • The config passed to the stage defines the initial state for bootsrapping the network. From this initial state, further interactions are designed using an event-driven approach, the stage handles events coming from the network and introduces extrinsic behavior by dispatching commands.
  • Low-level mini-protocols are in "auto" mode. Handshake, keep-alive, peer-sharing, etc don't have to be manually controlled, the network stack assumes an unambiguous behavior and automates the whole interaction across connected peers.
  • High-level mini-protocols that involve data flows provide specific network-level events and commands allowing external consumers to interact with them. This includes chain-sync, block-fetch, tx-submit, etc. There're no assumptions regarding the control-flow, but some of the details are abstracted way so that consumer can interact with the network as a whole instead of dealing with fine-grained interactions of particular peers.
  • The "pull" stage main concern is to mediate between downstream stages and the state of the network. It provides a "request" input port where stages can ask for specific actions and data output ports for dispatching headers and blocks.

@coderabbitai

coderabbitai Bot commented Sep 2, 2025

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch scarmuega/p2p-integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@abailly abailly left a comment

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.

This looks surprisingly simple and I think this would fit into pure_stage framework, but we would need a bit of experimenting to see how that would work. Is pallas2 in a workable stage so that we can experiment integrating this code in a real environment?
also, can you point us at other example use of the framework to get some insights on how far it goes and how to handle the subtle cases you highlighted in comments?

}

#[derive(Default)]
pub struct WorkUnit {

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.

Instead of stitching together Options would it not make sense to have a composable enum, something like:

pub enum WorkUnit {
  Network(InitiatorCommand),
  Sync(ChainSyncEvent),
  Block(BlockBodyData),
  More(Box<WorkUnit>)
} 

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The problem is that commands and data dispatch are orthogonal to each other. In any given tick you might want to send a network command AND dispatch data. Eg: after a header, you will want to dispatch the header and continue with the sync.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I now see that you have a continuation in your enum example, that could work to concatenate both actions.

Personally, I find the other struct more straight forward, but feel free to change it if you opt for the other one.

#[derive(Stage)]
// TODO: revisit stage name. I would prefer something like "network", "p2p", etc. I'll leave the
// decision for maintainers since it might affect tracing.
#[stage(name = "stage.chain_sync_client", unit = "WorkUnit", worker = "Worker")]

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.

stage.network.upstream would seems to be a good fit

}

impl Worker {
fn handle_network(&self, event: Option<InitiatorEvent>) -> WorkSchedule<WorkUnit> {

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.

passing an Option<> here feels a bit weird, why not avoid calling the function altogether if we don't have an event to handle?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I kind of agree. I did this to improve readability on the select! branches and emphasize the fact that returning idle for an empty event is a business logic decision (as opposed to glue code between functions).

One could make use of empty network ticks to provide metrics about the absence of network activity (eg: a counter of idle event).

Having said that, feel free to change it if the alternative feels more suitable.

// continuation of the mini-protocol is unambiguous. If the fetch request involved more
// blocks, the network machinery will keep receiving and notifying us of new
// block bodies.
InitiatorEvent::BlockBodyReceived(pid, body) => {

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.

What's pid here? the peerId? This would seem useful to correlate with the request

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.

Or maybe not if don't care where the block body came from.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pid is short for peer_id. Saved me thousands of keystroke when building the network crate :)

Almost all network events have a pid tag. Consensus will certainly need pid on header events, but probably not for block fetch. It could be useful if you want to keep peer-level stats (eg: blocks received per peer, latency per peer, etc)


fn handle_request(&self, request: &DownstreamRequest) -> WorkSchedule<WorkUnit> {
match request {
DownstreamRequest::FetchBlock(range) => {

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.

How do we know which peer to send the request to? This seems important because there are some security implications in a peer not being able to deliver a block it's supposed to have (eg. they sent a header but fail to deliver block)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The assumption here is that choosing a particular peer from the hot peers is a low-level detail that could be hidden from the consumer. If this design has implication on consensus security, we can certainly modify the api so that the specific peer is specified by the caller.

@scarmuega

Copy link
Copy Markdown
Contributor Author

The pallas-network2 is in a usable state, yes.

Not battle tested, not the best test coverage, but ready for beta. Although dev is still ongoing, my plan is to deploy it on some low-risk environments to start gaining some real-usage feedback.

This example contains a working version of a naive node doing chainsync + blockfetch (plus all of the low-level: peer discovery, handshake, keep-alive, etc).

I don't have examples for the subtleties (back-pressure, config sweet spot, etc) that I mention in the comments; mainly because they depend on the architecture of the consumer, and we don't have many consumers ATM.

@abailly

abailly commented Sep 3, 2025

Copy link
Copy Markdown
Contributor

Thanks a lot for the detailed answered @scarmuega, that's an already a great start!

Another question: How does this all work on the server side, eg. to serve downstream peers?

@rkuhn

rkuhn commented Sep 5, 2025

Copy link
Copy Markdown
Contributor

Thanks @scarmuega, this is a nice improvement that will make it much easier to integrate with different execution models (like pure-stage). While studying the code of pallas_network2 I found some aspects that I’d like to ask about.

Initiator / Responder

Currently only the initiator role is implemented, with only the initiator sides of the respective miniprotocols. What is your vision for how to offer the responder sides of the protocols and also the responder role of the whole endpoint? Reading the network spec §1.4.1 it seems that the initiator might also run the responder side of the miniprotocols, so would you have a separate ResponderBehavior or extend the InitiatorBehavior such that it serves both parts and should be renamed to N2NBehavior or similar?

On this note: we’ll need to have the responder functionality ready before we can switch from pallas_network to pallas_network2.

Flow control

It seems to me that the flow control mechanism described in §2.1.2 and §2.1.3 has not been implemented yet, correct? In particular, the per-channel receive buffers might grow past fixed bounds unless I’m missing something. It seems difficult to add the intended behaviour of disconnecting the node upon TCP back pressure within the chosen implementation approach.

Unbounded buffers

The use of FuturesUnordered to collect outstanding work does not prevent unbounded growth of the task queue managed inside. How do you intend to keep all work in progress bounded in size within the network stack? Bounding these queues would require for example that sending a block fetch request might result in a QueueOverflowError, which is currently not foreseen in the API.

Peer selection for block fetch

As you stated, the block fetch operation shall be dispatched to the appropriate peers by the p2p network stack, thus simplifying the Amaru consensus codebase. Currently, it seems that a block fetch request will be sent to one node only, namely the one whose visitor happens to be called first during a housekeeping operation. We’ll need to make this more selective and also ask a configurable number of peers instead of one — typically we ask the peer that sent the corresponding header, plus a small number of nodes that have proven to respond quickly in the past. How would you foresee to implement such logic given the current internal design?

Performance concerns

The design of the behaviour is such that it will dispatch each event to all sub-behaviours in sequence, while for housekeeping it will invoke all behaviours of all peer connections. This shouldn’t be an issue as long as the number of connections is small, but it may become an issue when managing 500 connections (which we think should be within the operational parameter range of Amaru). Currently, the housekeeping task only runs every 3sec by default (as per this PR), but I also saw that fetching a block is only triggered by the next housekeeping tick, which means that we’ll want to run housekeeping every couple of milliseconds or so — otherwise block fetch latency would impact the Amaru node’s timeliness constraints.

What is your intended evolution of this aspect of the implementation? It seems to me that focusing event-driven updates only on those tasks that need polling will become necessary at a certain usage intensity of this network stack.

@abailly

abailly commented Sep 30, 2025

Copy link
Copy Markdown
Contributor

@scarmuega Have you had a chance to look at @rkuhn's questions above? We really need to start ramping up our network stack sooner rather than later and would love to align on how pallas-network2 can align with Amaru needs.

@KtorZ

KtorZ commented Dec 11, 2025

Copy link
Copy Markdown
Contributor

Closing this as the work on the networking has continued in the meantime and diverged from this initial setup. Thanks for starting the discussion Santi;

@KtorZ KtorZ closed this Dec 11, 2025
@KtorZ KtorZ deleted the scarmuega/p2p-integration branch April 21, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants