-
Notifications
You must be signed in to change notification settings - Fork 949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add libp2p-request-response protocol. #1596
Conversation
This crate provides a generic implementation for request/response protocols, whereby each request is sent on a new substream.
Thanks for the work! From a purely technical perspective, I think that there are two problems that should be fixed:
There are a few more "ideological" points, that I disagree with, but am not strongly opposed:
|
The intention here is that a single instance of
That's not possible with the
You can implement
The inherent redundancy in the management of both peer addresses and the connected peers is a known problem for any
It is not |
FYI, I'm currently trying out / experimenting with some changes for the two points mentioned below and will report back here later.
|
I admit that I'm raising this point because it is relatively difficulty to compose multiple
Indeed
Yes! In Substrate/Polkadot, the task that polls the
It is not the same thing to resolve with an error and to not advertise the protocol at all. The list of protocols that In other words, if you return a protocol as part of |
fbcf59c
to
6460a11
Compare
1. Implement a custom ProtocolsHandler instead of using the OneShotHandler for better control and error handling. In particular, all request/response sending/receiving is kept in the substreams upgrades and thus the background task of a connection. 2. Support multiple protocols (usually protocol versions) with a single `RequestResponse` instance, with configurable inbound/outbound support.
6460a11
to
89ea70a
Compare
I addressed these two points with this commit and also added support for inbound-only or outbound-only configuration of protocols. An excerpt from the crate documentation: //! ## Protocol Families
//!
//! A single [`RequestResponse`] instance can be used with an entire
//! protocol family that share the same request and response types.
//! For that purpose, [`RequestResponseCodec::Protocol`] is typically
//! instantiated with a sum type.
//!
//! ## One-Way Protocols
//!
//! The implementation supports one-way protocols that do not
//! have responses. In these cases the [`RequestResponseCodec::Response`] can
//! be defined as `()` and [`RequestResponseCodec::read_response`] as well as
//! [`RequestResponseCodec::write_response`] given the obvious implementations.
//! Note that `RequestResponseMessage::Response` will still be emitted,
//! immediately after the request has been sent, since `RequestResponseCodec::read_response`
//! will not actually read anything from the given I/O stream.
//! [`RequestResponse::send_response`] need not be called for one-way protocols,
//! i.e. the [`ResponseChannel`] may just be dropped.
//!
//! ## Limited Protocol Support
//!
//! It is possible to only support inbound or outbound requests for
//! a particular protocol. This is achieved by instantiating `RequestResponse`
//! with protocols using [`ProtocolSupport::Inbound`] or
//! [`ProtocolSupport::Outbound`]. Any subset of protocols of a protocol
//! family can be configured in this way. Such protocols will not be
//! advertised during inbound respectively outbound protocol negotiation
//! on the substreams. The integration test shows a simple example of a protocol implementation used with this behaviour. |
Since the code in this PR is now using a custom |
/// The type of protocol(s) or protocol versions being negotiated. | ||
type Protocol: ProtocolName + Send + Sync + Clone; | ||
/// The type of inbound and outbound requests. | ||
type Request: Send + Clone; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why Clone
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is a requirement on inbound events to any ProtocolsHandler
(due to the general possibility of NotifyHandler::All
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where in the type-system is this enforced?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ExpandedSwarm
needs Clone
on TInEvent
since notify_all
needs Clone
on TInEvent
. Is that what you were asking for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that removing Clone
does not cause compilation to fail. Should the bound not be required by the ProtocolsHandler::InEvent
type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that removing Clone does not cause compilation to fail.
Indeed, that is true, the bound here does not seem necessary. Here InEvent
is struct RequestProtocol<TCodec>
which has a field of type TCodec::Request
together with [derive(Clone)]
. So certainly RequestProtocol
cannot be Clone
without TCodec::Request
being Clone
(and sure enough the included test with a ping/pong protocol complains firstly about the missing Clone
for struct Ping
due to the requirement for TInEvent: Clone
from the Swarm
). So the TCodec::Request: Clone
bound is probably "implied" by the Clone
derive on RequestProtocol
.
Should the bound not be required by the ProtocolsHandler::InEvent type?
You mean as an alternative to putting the bound in the ExpandedSwarm
impl? That would probably work - we have many such cases where the Swarm
or Network
is not really useable without certain constraints on some associated types, yet the bounds are placed on methods or impls of the Swarm
or Network
, instead of on the associated type. I personally would prefer the bounds to be directly on the associated types in these cases, but it is probably not something for this PR.
In any case, I will remove the seemingly unnecessary Clone
constraint here, thanks for pointing that out.
/// Reads a response from the given I/O stream according to the | ||
/// negotiated protocol. | ||
fn read_response<'a, T>(&mut self, protocol: &Self::Protocol, io: &'a mut T) | ||
-> BoxFuture<'a, Result<Self::Response, io::Error>> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about using async-trait
for this trait and writing:
async fn read_response<T>(&mut self, protocol: &Self::Protocol, io: &mut T) -> io::Result<Self::Response>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm personally not a fan of async-trait
. It hides things from you for a very minimal gain. No strong opinion on this however.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the suggestion. I wasn't sure how controversial it would be and was even curious if someone would suggest it. While I have no strong preference and it is a relatively small convenience, I do see the appeal in getting syntactic uniformity for async (i.e. future-returning) methods, whether in traits or not. In terms of hiding things I personally don't think it makes the situation worse because the code expands in a very similar way to that of the existing built-in async fn
s for non-trait methods (subject to the inherent limitations, of course), which one needs to understand in any case. So I have async-trait
a try: 9c6e6b4. If there are strong objections, please let me know.
where | ||
TCodec: RequestResponseCodec + Send + Sync + 'static, | ||
{ | ||
type Output = (); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not quite understand why this InboundUpgrade
does not yield a request, but instead uses oneshot
channels to emit the request, wait for the response and send it back all in one go. Is it not the responsibility of a ProtocolsHandler
to receive the inbound upgrade, i.e. the request and then command the sending back of the response?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is motivated here. In essence, it makes for a much simpler implementation (I had the obvious one you suggest before) for a variety of reasons:
- The protocols handler does not need to track pending inbound substreams as well as the subsequent sending of responses, e.g. via another dedicated
FuturesUnordered
. - The protocols handler does not require another command for sending responses back from the behaviour to the handler and thus
TResponse: Clone
as well. - The protocols handler does not need to track timeouts for pending responses to inbound substreams - the
InboundUpgrade
already has a timeout managed by the Swarm, just like theOutboundUpgrade
. Otherwise, without timeouts after the request has "left" theInboundUpgrade
, and the handler itself having to keep inbound substreams around until it received a response, a single omission ofRequestResponse::send_response
would result in a memory leak in the handler if there is no timeout management in the handler for these substreams. Not only that but being forced to callsend_response
even for one-way protocols is inconvenient from the perspective of the API. All of these problems are avoided by keeping the read-request/send-response I/O inside theInboundUpgrade::apply_upgrade
. - Lastly, but certainly not most importantly, there is the symmetry:
OutboundUpgrade
(send request -> read response);InboundUpgrade
(read request -> send response).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not question the relative simplicity, but would you not agree that this deviates from how ProtocolHandler
s are intended to work? If it turns out that they introduce unnecessary complexity, does this not indicate that their role needs to be revisited?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not question the relative simplicity, but would you not agree that this deviates from how ProtocolHandlers are intended to work?
I honestly don't know about whether there is a specific way in which they are intended to work and thus how much this deviates. I chose this implementation purely based on its perceived benefits for the implementation of request/response-per-substream protocols in particular. In the general case, I don't think it is "normal" to send application-level requests/responses in Inbound/Outbound
upgrades at all, just to negotiate protocols. In that sense I don't even think it is intended behaviour for an InboundUpgrade
to even yield a request.
If it turns out that they introduce unnecessary complexity, does this not indicate that their role needs to be revisited?
I agree in general. I think there was actually some loose agreement at some point on trying to remove the notion of a ProtocolsHandler
altogether, possibly alongside rethinking the Swarm
, but I don't think anyone has seriously made an attempt to do that so far.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the general case, I don't think it is "normal" to send application-level requests/responses in Inbound/Outbound upgrades at all, just to negotiate protocols. In that sense I don't even think it is intended behaviour for an InboundUpgrade to even yield a request.
At the time when these traits were created, they were indeed only supposed to negotiate protocols. However the idea of using them for application-level requests/responses is the natural continuation of this. This pattern is already used everywhere, and I think it is indeed a better pattern compared to just negotiating protocols and handling the application-level logic in the ProtocolsHandler
. The only problem to me is that the names InboundUpgrade
and OutboundUpgrade
do not reflect this.
I agree in general. I think there was actually some loose agreement at some point on trying to remove the notion of a ProtocolsHandler altogether, possibly alongside rethinking the Swarm, but I don't think anyone has seriously made an attempt to do that so far.
At the time when libp2p-swarm
was designed, there were multiple factors in play:
- We were still on futures 0.1, and async/await weren't available.
- Rust didn't (and still doesn't) support existential types.
- We had no clear idea of where we were going, so we aimed for maximum flexibility.
- Earlier versions of libp2p were often subject to race conditions.
The current design of libp2p-swarm
is unfortunately the trade-off between all these.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At the time when these traits were created, they were indeed only supposed to negotiate protocols. However the idea of using them for application-level requests/responses is the natural continuation of this. This pattern is already used everywhere, and I think it is indeed a better pattern compared to just negotiating protocols and handling the application-level logic in the ProtocolsHandler. The only problem to me is that the names InboundUpgrade and OutboundUpgrade do not reflect this.
That is what I thought, thanks for the clarification.
The new crate needs to be added to the |
I'll review in details later, but looks good overall, thanks! 👍 |
where | ||
TCodec: RequestResponseCodec + Send + Sync + 'static, | ||
{ | ||
type Output = (); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the general case, I don't think it is "normal" to send application-level requests/responses in Inbound/Outbound upgrades at all, just to negotiate protocols. In that sense I don't even think it is intended behaviour for an InboundUpgrade to even yield a request.
At the time when these traits were created, they were indeed only supposed to negotiate protocols. However the idea of using them for application-level requests/responses is the natural continuation of this. This pattern is already used everywhere, and I think it is indeed a better pattern compared to just negotiating protocols and handling the application-level logic in the ProtocolsHandler
. The only problem to me is that the names InboundUpgrade
and OutboundUpgrade
do not reflect this.
I agree in general. I think there was actually some loose agreement at some point on trying to remove the notion of a ProtocolsHandler altogether, possibly alongside rethinking the Swarm, but I don't think anyone has seriously made an attempt to do that so far.
At the time when libp2p-swarm
was designed, there were multiple factors in play:
- We were still on futures 0.1, and async/await weren't available.
- Rust didn't (and still doesn't) support existential types.
- We had no clear idea of where we were going, so we aimed for maximum flexibility.
- Earlier versions of libp2p were often subject to race conditions.
The current design of libp2p-swarm
is unfortunately the trade-off between all these.
/// Initiates sending a response to an inbound request. | ||
/// | ||
/// The provided `ResponseChannel` is obtained from a | ||
/// [`RequestResponseMessage::Request`]. | ||
pub fn send_response(&mut self, ch: ResponseChannel<TCodec::Response>, rs: TCodec::Response) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: maybe add a comment indicating that the response might be silently discarded, in case we took too long to build the response or if the remote has now disconnected?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I updated the commentary in 7bfdff5.
/// | ||
/// See [`RequestResponse::send_response`]. | ||
#[derive(Debug)] | ||
pub struct ResponseChannel<TResponse> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Other similar suggestion: maybe add a pub fn is_known_stale(&self) -> bool
method to ResponseChannel
?
In protocols where building the response to a request is CPU-intensive or IO-intensive, it might be a good idea to call is_known_stale()
beforehand and discard the ResponseChannel
if true
is returned.
Additionally, in situations where the program accumulates incoming requests at faster rate than it processes them, it would help to discard ResponseChannel
s that we know have already timed out. Otherwise, the program would spend most of its time building responses to timed out requests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. I added ResponseChannel::is_open
in 7bfdff5.
Apparently the compiler just needs some help with the scope of borrows, which is unfortunate.
Also expand the commentary on `send_response` to indicate that responses may be discard if they come in too late.
As an analogue of `ResponseChannel::is_open` for outbound requests.
Just to avoid surprises and since these changes are no longer necessary for this PR, I'm inclined to revert the introduction of the |
Since `libp2p-request-response` is no longer using it.
This is a proposal for #1562. A new protocol crate
libp2p-request-response
is introduced. From the crate documentation:As can be seen, I went for a dedicated trait instead of having users implement all of
UpgradeInfo
InboundUpgrade
andOutboundUpgrade
as I think it is the most convenient and avoids all the repetitive parts associated with implementing these traits.Note further that I made some changes to the
OneShotHandler
, most notably introducing theOneShotEvent
output type so thatProtocolsHandlerUpgrErr::Timeout
no longer results in closing the connection but instead reporting the timeout to the behaviour (this relates somewhat to #1530. I think closing the connection is never what you want on a request timeout - if the connection is actually broken in some way other errors will occur and result in the connection being closed, and if the remote peer is "broken" closing the connection won't help if we just open one again for the next request but just increase connection churn. If this change is nevertheless subject to debate we could introduce a configuration option). Then in order for the behaviour to associate timeouts with outbound requests if necessary, I introducedOneShotOutboundInfo
which the outbound protocol upgrade must implement (a blanket implementation for()
is given), thus making use of the previously unusedOneShotHandler::OutboundOpenInfo
.