Found while adding end-to-end coverage for #724. This is pre-existing and affects the trailers shipped by #721/#722, not just the new one.
What races
executeQueryArrow sets its trailers from inside the SetBodyStreamWriter callback:
That callback runs on the goroutine fasthttp starts in NewStreamReader (stream.go:42). Meanwhile the connection goroutine serialises the response head: Response.Write → writeBodyStream → ResponseHeader.Write → AppendBytes → appendStatusLine → formatStatusLine.
Both mutate the same ResponseHeader scratch buffer. formatStatusLine writes h.bufKV.value; Set → initHeaderKV writes h.bufKV. Race detector output:
WARNING: DATA RACE
Write at 0x00c0003f0d80 by goroutine 34:
fasthttp.initHeaderKV() header.go:3254
fasthttp.(*ResponseHeader).Set() header.go:1489
api.(*QueryHandler).executeQueryArrow.func5() query_arrow.go (trailer Set)
fasthttp.NewStreamReader.func1() stream.go:44
Previous write at 0x00c0003f0d80 by goroutine 32:
fasthttp.formatStatusLine() status.go:167
fasthttp.(*ResponseHeader).appendStatusLine() header.go:2366
fasthttp.(*ResponseHeader).Write() header.go:2307
fasthttp.(*Response).writeBodyStream() http.go:1982
Why there is no happens-before edge
NewStreamReader uses fasthttputil.NewPipeConns(), which is buffered: a write from the stream writer does not block waiting for the connection goroutine to read, so it establishes no ordering against the earlier h.Write(w). I confirmed this by making the stream writer write and flush a byte before the Set; the race still fires.
The other direction is fine: Set → bw.Flush() → pw.Close() → reader EOF → writeTrailer is properly ordered, which is why trailer values arrive intact in practice.
Why CI does not catch it today
No test drives executeQueryArrow to its success path over a real HTTP response. TestExecuteQueryArrowReturnsConnectionOnPanic goes through the panic path, and that path deliberately does not set trailers. #722's own comment records the race for the panic path:
By this point fasthttp's connection goroutine may already be serialising the response header, and writing a trailer from this goroutine races with it (caught by the race detector).
The same race exists on the normal path and was never exercised.
Impact
Concurrent writes into a shared slice while the status line is being formatted. Observable as a corrupted status line or corrupted trailer key/value on an Arrow IPC response. Timing-dependent and rare, but not excluded by the memory model.
Why it blocks coverage
Any end-to-end success-path test of POST /api/v1/query/arrow trips it, because Arc-Execution-Time-Ms is set unconditionally. #724 therefore ships with unit coverage of its trailer constant and wiring but no end-to-end assertion that the handler sets it. Fixing this unblocks that test.
Possible directions
There is no race-free way to set a trailer value from the stream-writer goroutine with the fasthttp 1.51 API, since the value has to land in the same ResponseHeader the connection goroutine is serialising. Options worth weighing:
- Move the signals out of trailers and into the body where the format allows. Not possible for Arrow IPC, which has no envelope.
- Carry them in pre-body response headers where the value is known before streaming. Works for
Arc-Rows-Capped only as "a cap applies", not "the cap was reached".
- Upstream a synchronised trailer-set API in fasthttp.
- Serialise access with our own mutex around every
ResponseHeader mutation, which only helps if fasthttp's own writes take the same lock. They do not, so this does not actually fix it.
Found while adding end-to-end coverage for #724. This is pre-existing and affects the trailers shipped by #721/#722, not just the new one.
What races
executeQueryArrowsets its trailers from inside theSetBodyStreamWritercallback:internal/api/query_arrow.go—respHeader.Set(arrowExecutionTimeTrailer, ...)on every responserespHeader.Set(arrowStreamTruncatedTrailer, ...)on the error pathrespHeader.Set(arrowRowsCappedTrailer, ...)on the capped path (A governance row cap is indistinguishable from a complete result #724)That callback runs on the goroutine fasthttp starts in
NewStreamReader(stream.go:42). Meanwhile the connection goroutine serialises the response head:Response.Write→writeBodyStream→ResponseHeader.Write→AppendBytes→appendStatusLine→formatStatusLine.Both mutate the same
ResponseHeaderscratch buffer.formatStatusLinewritesh.bufKV.value;Set→initHeaderKVwritesh.bufKV. Race detector output:Why there is no happens-before edge
NewStreamReaderusesfasthttputil.NewPipeConns(), which is buffered: a write from the stream writer does not block waiting for the connection goroutine to read, so it establishes no ordering against the earlierh.Write(w). I confirmed this by making the stream writer write and flush a byte before theSet; the race still fires.The other direction is fine:
Set→bw.Flush()→pw.Close()→ reader EOF →writeTraileris properly ordered, which is why trailer values arrive intact in practice.Why CI does not catch it today
No test drives
executeQueryArrowto its success path over a real HTTP response.TestExecuteQueryArrowReturnsConnectionOnPanicgoes through the panic path, and that path deliberately does not set trailers. #722's own comment records the race for the panic path:The same race exists on the normal path and was never exercised.
Impact
Concurrent writes into a shared slice while the status line is being formatted. Observable as a corrupted status line or corrupted trailer key/value on an Arrow IPC response. Timing-dependent and rare, but not excluded by the memory model.
Why it blocks coverage
Any end-to-end success-path test of
POST /api/v1/query/arrowtrips it, becauseArc-Execution-Time-Msis set unconditionally. #724 therefore ships with unit coverage of its trailer constant and wiring but no end-to-end assertion that the handler sets it. Fixing this unblocks that test.Possible directions
There is no race-free way to set a trailer value from the stream-writer goroutine with the fasthttp 1.51 API, since the value has to land in the same
ResponseHeaderthe connection goroutine is serialising. Options worth weighing:Arc-Rows-Cappedonly as "a cap applies", not "the cap was reached".ResponseHeadermutation, which only helps if fasthttp's own writes take the same lock. They do not, so this does not actually fix it.