-
Notifications
You must be signed in to change notification settings - Fork 108
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
Specialize the unary RPC flow internally #609
Comments
An alternative to the suggestion above would be a means to independently specialize the request-handling from the response-handling flows. This would allow the same optimizations unlocked above for unary RPCs to also be used for server-streaming RPCs, which also have a unary request. Here's a quick sketch of what that might look like: type protocolClient interface {
Peer() Peer
WriteRequestHeader(StreamType, http.Header)
// Use for unary and server-stream RPCs.
NewUnaryRequestConn(context.Context, Spec, http.Header, req any) streamingResponse
// Use for client-stream and bidi-stream RPCs.
NewStreamingRequestConn(context.Context, Spec, http.Header) streamingClientConn
}
type streamingResponse interface {
Receive(any) error
ResponseHeader() http.Header
ResponseTrailer() http.Header
CloseResponse() error
}
type streamingClientConn interface {
streamingResponse
Spec() Spec
Peer() Peer
Send(any) error
RequestHeader() http.Header
CloseRequest() error
}
func unaryResponse(str streamingResponse, resp any) error {
// used by unary and client-stream RPCs to extract a unary response
// message or an error from the given response.
// ...
} @akshayjshah, @mattrobenolt, @emcfarlane, thoughts? (For more context, see thread starting with #611 (comment)). |
Yup, that much more matches my expectations of the abstraction here. Then the client vs server conn implementations are composable to make any of the permutations, and bidi includes the extra overhead of needing a goroutine to do both sides concurrently. |
The client-side still needs this, too. The goroutine is not needed to do both concurrently but rather is necessary in order to stream the request. That's just the way the In the above outline, of just two methods, the latter one ( |
Sure that's fair. That's what I said might be more of a limitation of stdlib APIs available to us. Theoretically it wouldn't need it, but doesn't matter. I think treating client stream with the bidi path is more than acceptable. |
@emcfarlane, your initial approach in 9c76410 is actually pretty close to the above suggestion. Instead of having the split in logic be in the |
The problem is with the request body, the Adding a connect-go/protocol_connect.go Line 446 in b6d44ec
For grpc and grpc-web the duplexHTTPCall can't be switched out for unaryRequestHTTPCall as easily as there's no separate grpcUnaryClientConn : Line 326 in b6d44ec
To reduce the changes needed I want to replace This makes the |
For my understanding, why is it relevant to implement GetBody? From my understanding and experiments around here, this is used to replay a request in error, but only if a request is deemed idempotent: https://cs.opensource.google/go/go/+/refs/tags/go1.21.3:src/net/http/transport.go;l=87-94 Personally, I've dealt with this for our load balancer situation, and we explicitly do not want a GetBody, since none of our RPCs would be safe to be idempotent. Granted, Connect has awareness of idempotency, but that's bundled up with a GET request, and doesn't have a body anyways. https://cs.opensource.google/go/go/+/refs/tags/go1.21.3:src/net/http/request.go;l=1440-1454 Given that this would be for our POST path, I'm not sure it'd even be used, at least by the default stdlib http.Transport.
I think that makes sense inside the the *HTTPCall implementation. I'd consider that closer to our "transport" and makes sense to me that it would determine how to do this. |
@mattrobenolt, it is used. See #541. The HTTP library can also replay a request if it is certain that it was not processed. This happens, for example, in HTTP/2 when the server is doing graceful shutdown and sends a GOAWAY frame with an error code of NO_ERROR. |
Interesting, TIL! I had assumed if the body was read at all, this is quite unsafe. It's not a scenario I guess we've encountered. |
I'm proposing the // Send the payload to the server.
func (d *duplexHTTPCall) Send(payload messagePayload) error
// messagePayload a single message for a stream. It allows for rewinding of the underlying
// source to support retries.
type messagePayload interface {
io.Reader
io.WriterTo
// Rewind seeks the payload to the beginning.
Rewind()
} With two implementations, one enveloped and the other based on a bytes buffer. Extending the type envelope struct {
Data *bytes.Buffer
Flags uint8
offset uint // Offset into Data
} And the bytes buffer could be: type messageBuffer struct {
Data *bytes.Buffer
offset uint // Offset into Data
}
// TODO: methods to implement messagePayload similar to envelope. @akshayjshah, @mattrobenolt, @jhump what do you think about this API? How should the |
For func (e *envelopeWriter) Marshal(dst io.Writer, msg any) *Error Both the client func (e *envelopeWriter) MarshalEnvelope(data *bytes.Buffer, msg any) (*envelope, *Error)
func (e *envelopeWriter) Marshal(dst io.Writer, msg any) (*Error) {
buffer := e.bufferPool.Get()
defer e.bufferPool.Put(buffer)
env, err := e.MarshalEnvelope(msg)
if err != nil { return err }
if _, err := env.WriteTo(dst); err != nil { return err }
} |
I think you'll want another method in the Release(*bufferPool) So when the message is no longer needed, it can be returned to the pool. As I mentioned F2F, I was bothered by type messagePayload interface {
// Creates a new reader for reading the payload data.
NewReader() io.Reader
// Writes the payload data to w.
WriteTo(w io.Writer) (n int64, err error)
// Returns any underlying *bytes.Buffer instances to pool.
// This payload must not be used again after this is called.
Release(pool *bufferPool)
} This way, a message payload implementation (i.e. So the value returned from Does that make sense? What do you think? |
|
Sure. But that case will never happen here. We either need to use it as a reader (for non-streaming request messages) or to write (everything else). Seems strange to complicate the implementation of |
I think we'd still want the Request body as an |
Okay. That's fair. I guess that's really only for Connect unary, but since that is likely a significant majority of RPC traffic, that does make sense. |
In the current Currently a unary call effectively does: rdr, wtr := io.Pipe()
req.Body = rdr
go client.Do(ctx, req)
wtr.Write(buf) // Blocks on Read/Close The new rdr := &payloadCloser{buf: buf}
req.Body = rdr
req.GetBody = func() (io.ReadClosure, error) {
return rdr, rdr.Rewind()
}
client.Do(ctx, req)
rdr.Wait() // Blocks on Read/Close Both |
An alternative to the API on envelopeWriter above would be to swap the type sender interface {
Send(messagePayload) (int64, error)
} With a simple adapter for type writeSender struct {
writer io.Writer
}
func (s writeSender) Send(payload messagePayload) error {
return payload.WriteTo(s.writer)
} This would keep the semantics that |
Implemented the sender API here: emcfarlane/connect-go@main...ed/sender annoyingly this increases allocations removing some of the benefits we gain. I think related to allocating the interfaces for the sender args, maybe I missed something. Are we happy with this approach?
|
Resolved in #649. |
Currently, all RPCs flow through the same internal abstraction:
duplexHTTPCall
. This abstraction can accommodate all manner of calls, including full-duplex bidirectional calls, but it is overkill for unary RPC. Some of the machinery needed to support bidirectional streaming (synchronization mechanisms and an extra goroutine andio.Pipe
per call) is pure overhead and unnecessary for simple, unary RPCs.We can eliminate this overhead by providing a dedicated flow for unary RPCs via a new method on the
protocolClient
interface. Something like so:A very naive implementation of this new method might look like so (just to demonstrate what it conceptually does):
But the value of it is that it enables a much more bespoke flow that eschews the under-the-hood complexity of the
StreamingClientConn
(which is implemented by way ofduplexHTTPConn
).Having this in place would also make it trivial to resolve #541 for unary RPCs in a way that doesn't further complicate
duplexHTTPConn
.The text was updated successfully, but these errors were encountered: