[PureGo] Add GracefulClose to transport Stream#489
Conversation
Encapsulate the clean-shutdown sequence (CloseSend, drain to io.EOF, Close) on rawStream so embedding streams don't re-implement it and get the order wrong. A bare Close cancels the context and the server sees an abrupt reset; draining to EOF lets it see an orderly close. ctx bounds the drain: on expiry or a stream error it hard-aborts and returns the cause, else nil. Close stays as the hard-abort escape hatch. Closes #487. Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
Note the send side is half-closed (no Send may follow) and make the 'later Close is a no-op' guarantee explicit: every return path releases resources first. Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
| // returns the cause, else nil. Must not be called concurrently with Recv, and no | ||
| // Send may follow (the send side is half-closed). A later Close is a no-op, since | ||
| // GracefulClose always releases resources before it returns. | ||
| func (s *Stream) GracefulClose(ctx context.Context) error { return s.gracefulClose(ctx) } |
There was a problem hiding this comment.
Nothing guarantees that this context has a deadline. Meaning we could hang here if the server is not sending acks on recv() channel.
For Open(....) you have explicit guards against this, where you check for a deadline and apply a default one if it's not present.
openCtx := ctx
if _, ok := ctx.Deadline(); !ok {
var cancelOpen context.CancelFunc
openCtx, cancelOpen = context.WithTimeout(ctx, defaultHandshakeTimeout)
defer cancelOpen()
}For safety we should have the same or equivalent mechanism here.
context.Background() passed to GracefulClose would hang forever if the server stalled during the drain, since the only exit was ctx expiry. Apply defaultHandshakeTimeout internally when no deadline is present, mirroring the same guard in Open. Add TestStreamGracefulCloseDefaultsDeadline to cover the no-deadline path (marked -short-skippable as it takes ~30s to fire). Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
…bound Add TestGracefulCloseCloseSendFailure to rawstream_test.go covering the path where CloseSend itself fails â�� verifies the error is returned and close() is called to hard-abort the stream. Tighten the elapsed-time assertion in TestStreamGracefulCloseHonorsDeadline from a fixed 5s bound to 5x the actual deadline (5*200ms = 1s), so the test catches genuine slowness rather than accepting a 4.9s return when the deadline was 200ms. Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
…bound Add TestGracefulCloseCloseSendFailure to rawstream_test.go covering the path where CloseSend itself fails â�� verifies the error is returned and close() is called to hard-abort the stream. Replace the wall-clock elapsed check in TestStreamGracefulCloseHonorsDeadline with errors.Is(err, context.DeadlineExceeded): asserts what the deadline mechanism actually returned rather than inferring it from timing, removing the flakiness entirely. Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
teodordelibasic-db
left a comment
There was a problem hiding this comment.
First pass just regarding nits and styling.
| // half-closed). Every return path calls close first, so a later close is a no-op. | ||
| func (s *rawStream[Req, Resp]) gracefulClose(ctx context.Context) error { | ||
| if err := s.closeSend(); err != nil { | ||
| s.close() // can't half-close cleanly; hard-abort |
There was a problem hiding this comment.
Let's tidy up these inline comments.
| req, err := stream.Recv() | ||
| if err == io.EOF { | ||
| if f.hangDrain { | ||
| <-stream.Context().Done() // withhold the clean end |
| if err != nil { | ||
| t.Fatalf("Recv during drain: got %v, want io.EOF", err) | ||
| } | ||
| stream.Close() // idempotent no-op after a graceful shutdown |
| // rawStream; this type adds EphemeralStream wire types and the two handshake | ||
| // hooks. As with the embedded rawStream, a Stream is not safe for concurrent | ||
| // Send and should use a single writer goroutine. | ||
| type Stream struct { |
There was a problem hiding this comment.
Unrelated to this PR and we can discuss and do a follow up if we decide to do so, but since we will probably have an ArrowStream, I think leaving this as Stream might be confusing. Maybe more natural would be GrpcStream. What do you think?
There was a problem hiding this comment.
Maybe we can name it EphemeralStream as the rpc it wraps? Because Arrow is also a grpc stream technically so it might be confusing - so we'd have rawStream as a shared base and then 1. EphemeralStream and 2. ArrowStream
There was a problem hiding this comment.
Also unrelated and potential follow up, do you think raw_stream.go might be more per Go file naming standards?
There was a problem hiding this comment.
Yes, I can rename it
Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
What changes are proposed in this pull request?
Adds
GracefulClose(ctx)to the pure-Go transportStream, encapsulating the clean-shutdown sequence so higher layers don't re-implement it and get the order wrong.rawstream.go— genericgracefulClose(ctx)onrawStream: half-close the send side (CloseSend), drain and discard remaining responses toio.EOF, then release resources (close). The future Arrow/FlightwireStreaminherits it for free. Draining to EOF lets the server see an orderly close; a bareClosecancels the context and the server sees an abrupt reset instead.ephemeral_stream.go— exposesfunc (s *Stream) GracefulClose(ctx context.Context) error;CloseSend/Closedocs updated to cross-reference it.Close()stays as the hard-abort escape hatch.ctxbounds the drain: on expiry or a non-EOF stream error it hard-aborts (viaclose) and returns the cause (ctx error preferred), elsenil. The ctx is bridged tocloseso a caller deadline unblocks the in-flightrecv, which otherwise waits on the stream's Close-only context.Why
Before this, the transport layer exposed only
Close(), which fires the context cancel — closing without a preceding drain makes the server observe an abruptRST_STREAMrather than an orderlyEND_STREAM. The correct sequence was documented onCloseSendbut nothing encapsulated it, so every higher layer (ingest, recovery) would have to re-implement it. This is the same trap the Rust gRPC path had to unwind.Raised in review of #463.
How is this tested?
transport_test.go:TestStreamGracefulClose— server ends the stream on half-close; drain reaches a cleanio.EOFandGracefulClosereturns nil.TestStreamGracefulCloseDrainsPending— an in-flight ack precedesio.EOF; the drain must discard it and keep going.TestStreamGracefulCloseHonorsDeadline— server never ends the stream (newhangDrainfake-server flag);GracefulClosereturns its ctx error promptly and leaves the stream torn down.gofmt,go vet, andgo test -race ./...all clean.Notes for reviewers
purego/internal/, no public API yet.Closes #487. Part of #467.