Skip to content

Commit 90a0942

Browse files
authored
test: add mTLS and offline async-query coverage (#83)
* test: add mTLS and offline async-query coverage WithTLSClientCertificate/WithTLSRootCertificate (added in the v9 mTLS support) had no test at all, and the async query API added for v9 -- the change driving the major version bump -- only had a test that requires a live spiced instance, with no offline coverage of its status/wait/results/cancel logic. Adds: - mtls_test.go: drives a real mutual-TLS handshake against a local TLS server that requires a client certificate, plus validation/error-path tests for the option functions and Init(). - async_offline_test.go: an in-process Flight DoAction mock server exercising submit/status/wait/results (including a real Arrow IPC round trip)/cancel/parameterized-query encoding without a live runtime. * fix: use non-deprecated arrow-go/flight APIs in the async test server golangci-lint's staticcheck flagged both as SA1019 deprecated in CI: array.NewRecord -> array.NewRecordBatch, and flight.NewFlightServer -> flight.NewServerWithMiddleware(nil) (equivalent with no middleware).
1 parent 6af5b0f commit 90a0942

2 files changed

Lines changed: 632 additions & 0 deletions

File tree

async_offline_test.go

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
package gospice
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"sync"
8+
"testing"
9+
"time"
10+
11+
"github.com/apache/arrow-go/v18/arrow"
12+
"github.com/apache/arrow-go/v18/arrow/array"
13+
"github.com/apache/arrow-go/v18/arrow/flight"
14+
"github.com/apache/arrow-go/v18/arrow/ipc"
15+
"github.com/apache/arrow-go/v18/arrow/memory"
16+
"google.golang.org/grpc/codes"
17+
"google.golang.org/grpc/status"
18+
)
19+
20+
// asyncActionHandler answers a single Flight DoAction call for one action type.
21+
type asyncActionHandler func(requestBody []byte) (responseBody []byte, err error)
22+
23+
// asyncTestServer is a minimal in-process Flight server that only implements
24+
// DoAction, so the async query API (SubmitAsyncQuery/GetAsyncQueryStatus/
25+
// GetAsyncQueryResult/CancelAsyncQuery) can be exercised without a real spiced.
26+
type asyncTestServer struct {
27+
flight.BaseFlightServer
28+
29+
mu sync.Mutex
30+
handlers map[string]asyncActionHandler
31+
calls map[string]int
32+
}
33+
34+
func newAsyncTestServer() *asyncTestServer {
35+
return &asyncTestServer{
36+
handlers: make(map[string]asyncActionHandler),
37+
calls: make(map[string]int),
38+
}
39+
}
40+
41+
func (s *asyncTestServer) on(actionType string, h asyncActionHandler) {
42+
s.mu.Lock()
43+
defer s.mu.Unlock()
44+
s.handlers[actionType] = h
45+
}
46+
47+
func (s *asyncTestServer) callCount(actionType string) int {
48+
s.mu.Lock()
49+
defer s.mu.Unlock()
50+
return s.calls[actionType]
51+
}
52+
53+
func (s *asyncTestServer) DoAction(action *flight.Action, stream flight.FlightService_DoActionServer) error {
54+
s.mu.Lock()
55+
s.calls[action.Type]++
56+
h, ok := s.handlers[action.Type]
57+
s.mu.Unlock()
58+
59+
if !ok {
60+
return status.Errorf(codes.Unimplemented, "asyncTestServer: no handler registered for action %s", action.Type)
61+
}
62+
body, err := h(action.Body)
63+
if err != nil {
64+
return err
65+
}
66+
return stream.Send(&flight.Result{Body: body})
67+
}
68+
69+
// startAsyncTestServer starts srv on an ephemeral local port and returns its
70+
// grpc:// address, ready to pass to WithFlightAddress.
71+
func startAsyncTestServer(t *testing.T, srv *asyncTestServer) string {
72+
t.Helper()
73+
74+
fs := flight.NewServerWithMiddleware(nil)
75+
if err := fs.Init("127.0.0.1:0"); err != nil {
76+
t.Fatalf("error starting test flight server: %v", err)
77+
}
78+
fs.RegisterFlightService(srv)
79+
80+
go func() {
81+
_ = fs.Serve()
82+
}()
83+
t.Cleanup(fs.Shutdown)
84+
85+
return "grpc://" + fs.Addr().String()
86+
}
87+
88+
func newAsyncTestClient(t *testing.T, addr string) *SpiceClient {
89+
t.Helper()
90+
91+
spice := NewSpiceClient()
92+
if err := spice.Init(WithFlightAddress(addr)); err != nil {
93+
t.Fatalf("error initializing SpiceClient: %v", err)
94+
}
95+
t.Cleanup(func() { _ = spice.Close() })
96+
return spice
97+
}
98+
99+
// buildIPCChunk encodes a single-column, three-row Int64 record batch as an
100+
// Arrow IPC stream, mirroring what GetAsyncQueryResult returns for one chunk.
101+
func buildIPCChunk(t *testing.T) []byte {
102+
t.Helper()
103+
104+
schema := arrow.NewSchema([]arrow.Field{{Name: "n", Type: arrow.PrimitiveTypes.Int64}}, nil)
105+
106+
bldr := array.NewInt64Builder(memory.DefaultAllocator)
107+
defer bldr.Release()
108+
bldr.AppendValues([]int64{1, 2, 3}, nil)
109+
col := bldr.NewInt64Array()
110+
defer col.Release()
111+
112+
rec := array.NewRecordBatch(schema, []arrow.Array{col}, 3)
113+
defer rec.Release()
114+
115+
var buf bytes.Buffer
116+
w := ipc.NewWriter(&buf, ipc.WithSchema(schema))
117+
if err := w.Write(rec); err != nil {
118+
t.Fatalf("error writing IPC record: %v", err)
119+
}
120+
if err := w.Close(); err != nil {
121+
t.Fatalf("error closing IPC writer: %v", err)
122+
}
123+
return buf.Bytes()
124+
}
125+
126+
func TestAsyncQuerySubmitAndStatus(t *testing.T) {
127+
srv := newAsyncTestServer()
128+
srv.on(actionSubmitAsyncQuery, func(body []byte) ([]byte, error) {
129+
return []byte(`{"query_id":"q-1","status":"PENDING"}`), nil
130+
})
131+
srv.on(actionGetAsyncQueryStatus, func(body []byte) ([]byte, error) {
132+
return []byte(`{"query_id":"q-1","status":"RUNNING"}`), nil
133+
})
134+
135+
spice := newAsyncTestClient(t, startAsyncTestServer(t, srv))
136+
137+
q, err := spice.Query(context.Background(), "SELECT 1")
138+
if err != nil {
139+
t.Fatalf("error submitting async query: %v", err)
140+
}
141+
if q.ID() != "q-1" {
142+
t.Errorf("ID() = %q, want %q", q.ID(), "q-1")
143+
}
144+
if q.status != QueryStatusPending {
145+
t.Errorf("initial status = %q, want %q", q.status, QueryStatusPending)
146+
}
147+
148+
got, err := q.Status(context.Background())
149+
if err != nil {
150+
t.Fatalf("error polling status: %v", err)
151+
}
152+
if got != QueryStatusRunning {
153+
t.Errorf("Status() = %q, want %q", got, QueryStatusRunning)
154+
}
155+
}
156+
157+
func TestAsyncQueryWaitPollsUntilTerminal(t *testing.T) {
158+
srv := newAsyncTestServer()
159+
srv.on(actionSubmitAsyncQuery, func(body []byte) ([]byte, error) {
160+
return []byte(`{"query_id":"q-2","status":"PENDING"}`), nil
161+
})
162+
163+
var polls int
164+
srv.on(actionGetAsyncQueryStatus, func(body []byte) ([]byte, error) {
165+
polls++
166+
if polls < 3 {
167+
return []byte(`{"query_id":"q-2","status":"RUNNING"}`), nil
168+
}
169+
return []byte(`{"query_id":"q-2","status":"SUCCEEDED","result":{"total_row_count":0,"total_chunk_count":0}}`), nil
170+
})
171+
172+
spice := newAsyncTestClient(t, startAsyncTestServer(t, srv))
173+
174+
q, err := spice.Query(context.Background(), "SELECT 1")
175+
if err != nil {
176+
t.Fatalf("error submitting async query: %v", err)
177+
}
178+
179+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
180+
defer cancel()
181+
182+
got, err := q.Wait(ctx)
183+
if err != nil {
184+
t.Fatalf("error waiting for query: %v", err)
185+
}
186+
if got != QueryStatusSucceeded {
187+
t.Errorf("Wait() = %q, want %q", got, QueryStatusSucceeded)
188+
}
189+
if polls < 3 {
190+
t.Errorf("expected Wait to poll until the 3rd response, only polled %d times", polls)
191+
}
192+
}
193+
194+
func TestAsyncQueryResultsEmpty(t *testing.T) {
195+
srv := newAsyncTestServer()
196+
srv.on(actionSubmitAsyncQuery, func(body []byte) ([]byte, error) {
197+
return []byte(`{"query_id":"q-3","status":"PENDING"}`), nil
198+
})
199+
srv.on(actionGetAsyncQueryStatus, func(body []byte) ([]byte, error) {
200+
return []byte(`{"query_id":"q-3","status":"SUCCEEDED","result":{"total_row_count":0,"total_chunk_count":0}}`), nil
201+
})
202+
srv.on(actionGetAsyncQueryResult, func(body []byte) ([]byte, error) {
203+
return nil, status.Errorf(codes.NotFound, "no chunks for an empty result")
204+
})
205+
206+
spice := newAsyncTestClient(t, startAsyncTestServer(t, srv))
207+
208+
q, err := spice.Query(context.Background(), "SELECT 1 WHERE false")
209+
if err != nil {
210+
t.Fatalf("error submitting async query: %v", err)
211+
}
212+
213+
reader, err := q.Results(context.Background())
214+
if err != nil {
215+
t.Fatalf("error fetching results: %v", err)
216+
}
217+
defer reader.Release()
218+
219+
if reader.Next() {
220+
t.Error("expected no records for an empty result")
221+
}
222+
}
223+
224+
func TestAsyncQueryResultsWithData(t *testing.T) {
225+
chunk := buildIPCChunk(t)
226+
227+
srv := newAsyncTestServer()
228+
srv.on(actionSubmitAsyncQuery, func(body []byte) ([]byte, error) {
229+
return []byte(`{"query_id":"q-4","status":"PENDING"}`), nil
230+
})
231+
srv.on(actionGetAsyncQueryStatus, func(body []byte) ([]byte, error) {
232+
return []byte(`{"query_id":"q-4","status":"SUCCEEDED","result":{"total_row_count":3,"total_chunk_count":1}}`), nil
233+
})
234+
srv.on(actionGetAsyncQueryResult, func(body []byte) ([]byte, error) {
235+
return chunk, nil
236+
})
237+
238+
spice := newAsyncTestClient(t, startAsyncTestServer(t, srv))
239+
240+
q, err := spice.Query(context.Background(), "SELECT n FROM t")
241+
if err != nil {
242+
t.Fatalf("error submitting async query: %v", err)
243+
}
244+
245+
reader, err := q.Results(context.Background())
246+
if err != nil {
247+
t.Fatalf("error fetching results: %v", err)
248+
}
249+
defer reader.Release()
250+
251+
if !reader.Next() {
252+
t.Fatal("expected one record batch, got none")
253+
}
254+
rec := reader.RecordBatch()
255+
if rec.NumRows() != 3 {
256+
t.Errorf("NumRows() = %d, want 3", rec.NumRows())
257+
}
258+
col, ok := rec.Column(0).(*array.Int64)
259+
if !ok {
260+
t.Fatalf("column 0 is %T, want *array.Int64", rec.Column(0))
261+
}
262+
want := []int64{1, 2, 3}
263+
for i, w := range want {
264+
if got := col.Value(i); got != w {
265+
t.Errorf("row %d = %d, want %d", i, got, w)
266+
}
267+
}
268+
if reader.Next() {
269+
t.Error("expected exactly one record batch")
270+
}
271+
}
272+
273+
func TestAsyncQueryResultsFailure(t *testing.T) {
274+
srv := newAsyncTestServer()
275+
srv.on(actionSubmitAsyncQuery, func(body []byte) ([]byte, error) {
276+
return []byte(`{"query_id":"q-5","status":"PENDING"}`), nil
277+
})
278+
srv.on(actionGetAsyncQueryStatus, func(body []byte) ([]byte, error) {
279+
return []byte(`{"query_id":"q-5","status":"FAILED","error":{"error_code":"QueryExecutionError","message":"boom"}}`), nil
280+
})
281+
282+
spice := newAsyncTestClient(t, startAsyncTestServer(t, srv))
283+
284+
q, err := spice.Query(context.Background(), "SELECT 1/0")
285+
if err != nil {
286+
t.Fatalf("error submitting async query: %v", err)
287+
}
288+
289+
_, err = q.Results(context.Background())
290+
if err == nil {
291+
t.Fatal("expected an error for a failed query, got nil")
292+
}
293+
if got := err.Error(); !bytes.Contains([]byte(got), []byte("boom")) {
294+
t.Errorf("error %q does not mention the runtime's failure message", got)
295+
}
296+
}
297+
298+
func TestAsyncQueryCancel(t *testing.T) {
299+
srv := newAsyncTestServer()
300+
srv.on(actionSubmitAsyncQuery, func(body []byte) ([]byte, error) {
301+
return []byte(`{"query_id":"q-6","status":"RUNNING"}`), nil
302+
})
303+
srv.on(actionCancelAsyncQuery, func(body []byte) ([]byte, error) {
304+
return []byte(`{"query_id":"q-6","cancelled":true,"status":"CANCELLED"}`), nil
305+
})
306+
307+
spice := newAsyncTestClient(t, startAsyncTestServer(t, srv))
308+
309+
q, err := spice.Query(context.Background(), "SELECT 1")
310+
if err != nil {
311+
t.Fatalf("error submitting async query: %v", err)
312+
}
313+
314+
if err := q.Cancel(context.Background()); err != nil {
315+
t.Fatalf("error cancelling query: %v", err)
316+
}
317+
if q.status != QueryStatusCancelled {
318+
t.Errorf("status after Cancel() = %q, want %q", q.status, QueryStatusCancelled)
319+
}
320+
if got := srv.callCount(actionCancelAsyncQuery); got != 1 {
321+
t.Errorf("CancelAsyncQuery action called %d times, want 1", got)
322+
}
323+
}
324+
325+
func TestAsyncQueryWithParams(t *testing.T) {
326+
var submittedBody []byte
327+
srv := newAsyncTestServer()
328+
srv.on(actionSubmitAsyncQuery, func(body []byte) ([]byte, error) {
329+
submittedBody = body
330+
return []byte(`{"query_id":"q-7","status":"PENDING"}`), nil
331+
})
332+
333+
spice := newAsyncTestClient(t, startAsyncTestServer(t, srv))
334+
335+
_, err := spice.QueryWithParams(context.Background(), "SELECT * FROM t WHERE id = $1", 42)
336+
if err != nil {
337+
t.Fatalf("error submitting parameterized async query: %v", err)
338+
}
339+
340+
want := `"parameters":[42]`
341+
if !bytes.Contains(submittedBody, []byte(want)) {
342+
t.Errorf("submitted request body %s does not contain %s", submittedBody, want)
343+
}
344+
wantSQL := fmt.Sprintf(`"sql":%q`, "SELECT * FROM t WHERE id = $1")
345+
if !bytes.Contains(submittedBody, []byte(wantSQL)) {
346+
t.Errorf("submitted request body %s does not contain %s", submittedBody, wantSQL)
347+
}
348+
}

0 commit comments

Comments
 (0)