This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git


The following commit(s) were added to refs/heads/main by this push:
     new da6ec021 fix(flight/flightsql): recover real error when prepared DoPut 
stream closes early (#920)
da6ec021 is described below

commit da6ec021bd6eeb7453402c026ea26382506f0716
Author: Fredrik Fornwall <[email protected]>
AuthorDate: Fri Jul 10 18:41:50 2026 +0200

    fix(flight/flightsql): recover real error when prepared DoPut stream closes 
early (#920)
    
    **DISCLAIMERS**:
    - I'm not a user of this repository directly - the only reason this was
    encountered was due to flakyness in `arrow-adbc`, see
    https://github.com/apache/arrow-adbc/pull/4497
    - This PR was generated by AI. I have reviewed the code and is ready to
    make adjustments or to look into questions. Perhaps make comments less
    verbose, or adopt code to project conventions? Let me know!
    
    When a prepared statement's bound-parameter `DoPut` stream is torn down
    by the server before the client finishes sending (e.g. the server
    handler returns without draining the request stream), gRPC surfaces the
    parameter send as a bare `io.EOF`.
    
    Per gRPC's contract an `io.EOF` from a send only means "the stream ended
    -- receive to learn why", but `bindParameters` and `ExecuteUpdate`
    forwarded that EOF directly, so the caller saw an uninformative "EOF
    (Unknown)" instead of the server's actual status.
    
    Treat a send-side `io.EOF` as a signal to fall through to the receive,
    which recovers the real error (or the response the server produced
    before closing).
    
    Adds a regression test whose server returns `InvalidArgument` without
    reading the stream; a large binding forces the send to block on flow
    control and observe the teardown, so the test fails deterministically
    without the fix.
    
    Signed-off-by: Fredrik Fornwall <[email protected]>
---
 arrow/flight/flightsql/client.go      | 37 +++++++++----
 arrow/flight/flightsql/server_test.go | 99 +++++++++++++++++++++++++++++++++++
 2 files changed, 126 insertions(+), 10 deletions(-)

diff --git a/arrow/flight/flightsql/client.go b/arrow/flight/flightsql/client.go
index 192cec7b..e76b4163 100644
--- a/arrow/flight/flightsql/client.go
+++ b/arrow/flight/flightsql/client.go
@@ -1231,7 +1231,10 @@ func (p *PreparedStatement) ExecuteUpdate(ctx 
context.Context, opts ...grpc.Call
        }
        if p.hasBindParameters() {
                wr, err = p.writeBindParametersToStream(pstream, desc)
-               if err != nil {
+               // A bare io.EOF from a send means the server has already 
closed the
+               // stream; the authoritative status is delivered by the Recv 
below, so
+               // don't bail out early here (see bindParameters for the full 
rationale).
+               if err != nil && !errors.Is(err, io.EOF) {
                        return
                }
        } else {
@@ -1239,17 +1242,21 @@ func (p *PreparedStatement) ExecuteUpdate(ctx 
context.Context, opts ...grpc.Call
                wr = flight.NewRecordWriter(pstream, ipc.WithSchema(schema))
                wr.SetFlightDescriptor(desc)
                rec := array.NewRecordBatch(schema, []arrow.Array{}, 0)
-               if err = wr.Write(rec); err != nil {
+               if err = wr.Write(rec); err != nil && !errors.Is(err, io.EOF) {
                        return
                }
        }
 
-       if err = wr.Close(); err != nil {
-               return
-       }
-       if err = pstream.CloseSend(); err != nil {
-               return
+       // wr is nil only when writeBindParametersToStream returned an error 
above
+       // (e.g. a send failed); otherwise it is the writer to flush and close.
+       if wr != nil {
+               if err = wr.Close(); err != nil && !errors.Is(err, io.EOF) {
+                       return
+               }
        }
+       // Ignore CloseSend's error: if the server already tore the stream down 
the
+       // send side is gone, and the authoritative status is delivered by Recv.
+       _ = pstream.CloseSend()
        if res, err = pstream.Recv(); err != nil {
                return
        }
@@ -1272,11 +1279,21 @@ func (p *PreparedStatement) bindParameters(ctx 
context.Context, desc *pb.FlightD
                        return nil, err
                }
                wr, err := p.writeBindParametersToStream(pstream, desc)
-               if err != nil {
+               // gRPC hands back a bare io.EOF from a send once the server 
has closed
+               // the stream; its contract is that the real outcome -- a 
genuine error,
+               // or a response the server produced before closing -- must 
then be
+               // recovered with a receive. Fall through to 
captureDoPutPreparedStatementHandle
+               // (which performs that receive) rather than surfacing the 
uninformative
+               // EOF, which otherwise reaches the caller as "EOF (Unknown)".
+               if err != nil && !errors.Is(err, io.EOF) {
                        return nil, err
                }
-               if err = wr.Close(); err != nil {
-                       return nil, err
+               // wr is nil when writeBindParametersToStream returned an error 
above
+               // (e.g. a send failed); otherwise it is the writer to flush 
and close.
+               if wr != nil {
+                       if err = wr.Close(); err != nil && !errors.Is(err, 
io.EOF) {
+                               return nil, err
+                       }
                }
                pstream.CloseSend()
                if err = p.captureDoPutPreparedStatementHandle(pstream); err != 
nil {
diff --git a/arrow/flight/flightsql/server_test.go 
b/arrow/flight/flightsql/server_test.go
index ca07eef6..74dd59f7 100644
--- a/arrow/flight/flightsql/server_test.go
+++ b/arrow/flight/flightsql/server_test.go
@@ -857,6 +857,105 @@ func TestBaseServer(t *testing.T) {
        suite.Run(t, &FlightSqlServerSessionSuite{sessionManager: 
session.NewStatelessServerSessionManager()})
 }
 
+// earlyCloseServer models a Flight SQL server whose 
DoPutPreparedStatementQuery
+// handler returns before consuming the client's bound-parameter stream. gRPC
+// tears the request stream down as soon as the handler returns, so the 
client's
+// in-flight parameter send races that teardown and observes a bare io.EOF. Per
+// gRPC's contract that io.EOF only signals "the stream ended -- receive to 
learn
+// why", so the client must recover the real server status rather than 
surfacing
+// the uninformative EOF (which otherwise reaches callers as "EOF (Unknown)").
+type earlyCloseServer struct {
+       flightsql.BaseServer
+}
+
+func (*earlyCloseServer) CreatePreparedStatement(ctx context.Context, req 
flightsql.ActionCreatePreparedStatementRequest) 
(flightsql.ActionCreatePreparedStatementResult, error) {
+       return flightsql.ActionCreatePreparedStatementResult{
+               Handle: []byte("early-close"),
+               ParameterSchema: arrow.NewSchema([]arrow.Field{
+                       {Name: "n", Type: arrow.PrimitiveTypes.Int32, Nullable: 
true},
+               }, nil),
+       }, nil
+}
+
+func (*earlyCloseServer) DoPutPreparedStatementQuery(ctx context.Context, cmd 
flightsql.PreparedStatementQuery, reader flight.MessageReader, writer 
flight.MetadataWriter) ([]byte, error) {
+       // Deliberately return without draining reader so the request stream is 
torn
+       // down while the client is still sending its parameters.
+       return nil, status.Error(codes.InvalidArgument, "boom")
+}
+
+func (*earlyCloseServer) DoPutPreparedStatementUpdate(ctx context.Context, cmd 
flightsql.PreparedStatementUpdate, reader flight.MessageReader) (int64, error) {
+       // Same as the query handler: return without draining reader so the 
client's
+       // parameter send observes the teardown (the ExecuteUpdate path).
+       return 0, status.Error(codes.InvalidArgument, "boom")
+}
+
+func (*earlyCloseServer) ClosePreparedStatement(ctx context.Context, req 
flightsql.ActionClosePreparedStatementRequest) error {
+       return nil
+}
+
+// TestPreparedStatementDoPutEarlyClose is a regression test for a prepared
+// statement whose bound-parameter DoPut is torn down early by the server: the
+// client must surface the server's real error instead of a bare io.EOF. Both
+// the query path (Execute -> bindParameters) and the sibling update path
+// (ExecuteUpdate) carry the same fix, so each is exercised as a subtest.
+func TestPreparedStatementDoPutEarlyClose(t *testing.T) {
+       srv := flight.NewServerWithMiddleware(nil)
+       
srv.RegisterFlightService(flightsql.NewFlightServer(&earlyCloseServer{}))
+       require.NoError(t, srv.Init("localhost:0"))
+       go func() { _ = srv.Serve() }()
+       defer srv.Shutdown()
+
+       cl, err := flightsql.NewClient(srv.Addr().String(), nil, nil, 
dialOpts...)
+       require.NoError(t, err)
+       defer cl.Close()
+
+       ctx := context.Background()
+
+       // run prepares a statement, binds a deliberately large parameter set 
(well
+       // beyond gRPC's flow-control window) and drives exec. Because the 
server
+       // returns without reading, the window never opens, so the client's
+       // parameter send blocks and then observes the stream teardown as a bare
+       // io.EOF -- deterministically exercising the path that previously 
leaked
+       // "EOF (Unknown)" to the caller. The server's real status must surface.
+       run := func(t *testing.T, exec func(*flightsql.PreparedStatement) 
error) {
+               prep, err := cl.Prepare(ctx, "SELECT 1")
+               require.NoError(t, err)
+               defer prep.Close(ctx)
+
+               bldr := array.NewRecordBuilder(memory.DefaultAllocator, 
prep.ParameterSchema())
+               defer bldr.Release()
+               fb := bldr.Field(0).(*array.Int32Builder)
+               fb.Reserve(1 << 21)
+               for i := 0; i < 1<<21; i++ {
+                       fb.Append(int32(i))
+               }
+               rec := bldr.NewRecordBatch()
+               defer rec.Release()
+               prep.SetParameters(rec)
+
+               err = exec(prep)
+               require.Error(t, err)
+               st := status.Convert(err)
+               require.Equalf(t, codes.InvalidArgument, st.Code(),
+                       "expected the server's status to be surfaced, not a 
bare EOF; got: %v", err)
+               require.Contains(t, st.Message(), "boom")
+       }
+
+       t.Run("Execute", func(t *testing.T) {
+               run(t, func(p *flightsql.PreparedStatement) error {
+                       _, err := p.Execute(ctx)
+                       return err
+               })
+       })
+
+       t.Run("ExecuteUpdate", func(t *testing.T) {
+               run(t, func(p *flightsql.PreparedStatement) error {
+                       _, err := p.ExecuteUpdate(ctx)
+                       return err
+               })
+       })
+}
+
 func TestStatefulServerSessionCookies(t *testing.T) {
        // Generate session IDs deterministically
        sessionIDGenerator := func(ids []string) func() string {

Reply via email to