Skip to content

Commit 27c524f

Browse files
committed
Add QueryChunksContext
Reading a result through database/sql copies and boxes every value, which is significant overhead when the caller only forwards the rows. QueryChunksContext hands the native chunks to a callback instead, so values can be read straight out of the vectors.
1 parent 277ea9e commit 27c524f

4 files changed

Lines changed: 672 additions & 0 deletions

File tree

chunk_query.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package duckdb
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"database/sql/driver"
7+
"errors"
8+
9+
"github.com/duckdb/duckdb-go/v2/mapping"
10+
)
11+
12+
// QueryChunksContext executes a query and passes each native result chunk to
13+
// consume. It exists to read results without the per-row allocations of
14+
// database/sql: values can be read straight out of DuckDB's vectors, e.g., via
15+
// DataChunk.GetVarcharView.
16+
//
17+
// The chunks arrive in result order, and consume observes one chunk at a time.
18+
// Returning an error from consume stops the iteration and returns that error
19+
// unchanged, so a sentinel error stops the iteration early.
20+
// consume is not called at all for a result without rows.
21+
//
22+
// The connection is occupied for the whole call. consume must therefore not
23+
// issue queries on sqlConn, which would deadlock. Note that DuckDB materializes
24+
// the result before the first chunk is fetched, so this API bounds the memory
25+
// that the caller allocates, not the memory that DuckDB does.
26+
//
27+
// The same *DataChunk is reused for every chunk, and its C-allocated memory is
28+
// released once consume returns. Neither the chunk nor anything aliasing it may
29+
// be retained beyond that. args bypass the argument conversion of database/sql
30+
// and are bound as-is. If query holds multiple statements, all but the last are
31+
// executed, and only the last one yields chunks.
32+
func QueryChunksContext(
33+
ctx context.Context,
34+
sqlConn *sql.Conn,
35+
query string,
36+
consume func(*DataChunk) error,
37+
args ...any,
38+
) error {
39+
if sqlConn == nil {
40+
return getError(errAPI, errNilConn)
41+
}
42+
if consume == nil {
43+
return getError(errAPI, errNilChunkConsumer)
44+
}
45+
46+
values := make([]driver.Value, len(args))
47+
for i, arg := range args {
48+
values[i] = arg
49+
}
50+
51+
return sqlConn.Raw(func(driverConn any) error {
52+
conn, ok := driverConn.(*Conn)
53+
if !ok {
54+
return getError(errAPI, invalidConnError(driverConn))
55+
}
56+
return conn.queryChunksContext(ctx, query, argsToNamedArgs(values), consume)
57+
})
58+
}
59+
60+
// queryChunksContext prepares query on conn, streams its chunks to consume, and
61+
// closes the statement before returning.
62+
func (conn *Conn) queryChunksContext(
63+
ctx context.Context,
64+
query string,
65+
args []driver.NamedValue,
66+
consume func(*DataChunk) error,
67+
) error {
68+
if conn.closed {
69+
return errClosedCon
70+
}
71+
72+
cleanupCtx := conn.setContext(ctx)
73+
defer cleanupCtx()
74+
75+
return runWithCtxInterrupt(ctx, conn.conn, func(wctx context.Context) (err error) {
76+
prepared, err := conn.prepareStmts(wctx, query)
77+
if err != nil {
78+
return err
79+
}
80+
81+
defer func() {
82+
closeErr := prepared.Close()
83+
switch {
84+
case err != nil && closeErr != nil:
85+
err = errors.Join(err, closeErr)
86+
case closeErr != nil:
87+
err = closeErr
88+
}
89+
}()
90+
91+
return consumePreparedChunks(wctx, prepared, args, consume)
92+
})
93+
}
94+
95+
// consumePreparedChunks executes prepared and passes every result chunk to
96+
// consume, one at a time. It owns all C-allocated memory involved: the result is
97+
// destroyed on return, and each fetched chunk is freed after consume observes it.
98+
func consumePreparedChunks(
99+
ctx context.Context,
100+
prepared *Stmt,
101+
args []driver.NamedValue,
102+
consume func(*DataChunk) error,
103+
) error {
104+
result, err := prepared.execute(ctx, args)
105+
if err != nil {
106+
return err
107+
}
108+
defer mapping.DestroyResult(result)
109+
110+
columnCount := mapping.ColumnCount(result)
111+
columnNames := make([]string, columnCount)
112+
for i := range columnNames {
113+
columnNames[i] = mapping.ColumnName(result, mapping.IdxT(i))
114+
}
115+
116+
var chunk DataChunk
117+
chunk.columnNames = columnNames
118+
for {
119+
if err := ctx.Err(); err != nil {
120+
return err
121+
}
122+
nativeChunk := mapping.FetchChunk(*result)
123+
if nativeChunk.Ptr == nil {
124+
if errMsg := mapping.ResultError(result); errMsg != "" {
125+
if err := ctx.Err(); err != nil {
126+
return err
127+
}
128+
return getError(errAPI, getDuckDBError(errMsg))
129+
}
130+
return ctx.Err()
131+
}
132+
if err := chunk.initFromDuckDataChunk(nativeChunk, false); err != nil {
133+
mapping.DestroyDataChunk(&nativeChunk)
134+
return getError(errAPI, err)
135+
}
136+
137+
err := consumeResultChunk(&chunk, consume)
138+
if err != nil {
139+
return err
140+
}
141+
}
142+
}
143+
144+
// consumeResultChunk passes chunk to consume and frees its C-allocated memory.
145+
func consumeResultChunk(chunk *DataChunk, consume func(*DataChunk) error) error {
146+
defer chunk.close()
147+
return consume(chunk)
148+
}

chunk_query_bench_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package duckdb
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"fmt"
7+
"io"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
const benchChunkRowCount = 100_000
14+
15+
// benchChunkQuery serializes every row to a single VARCHAR column.
16+
var benchChunkQuery = fmt.Sprintf(
17+
`SELECT to_json(r)::VARCHAR FROM (
18+
SELECT i AS id, i::VARCHAR AS name, i * 1.5 AS value FROM range(%d) AS values(i)
19+
) AS r`, benchChunkRowCount)
20+
21+
func setupChunkBench(b *testing.B) (*sql.Conn, []byte) {
22+
b.Helper()
23+
db := openDbWrapper(b, "")
24+
conn := openConnWrapper(b, db, context.Background())
25+
b.Cleanup(func() {
26+
closeConnWrapper(b, conn)
27+
closeDbWrapper(b, db)
28+
})
29+
return conn, make([]byte, 0, 128*1024)
30+
}
31+
32+
// BenchmarkQueryChunks reads the result through the native chunk API.
33+
func BenchmarkQueryChunks(b *testing.B) {
34+
conn, buf := setupChunkBench(b)
35+
36+
b.ReportAllocs()
37+
for b.Loop() {
38+
buf = buf[:0]
39+
err := QueryChunksContext(context.Background(), conn, benchChunkQuery,
40+
func(chunk *DataChunk) error {
41+
for rowIdx := range chunk.GetSize() {
42+
value, valid, err := chunk.GetVarcharView(0, rowIdx)
43+
if err != nil {
44+
return err
45+
}
46+
if !valid {
47+
continue
48+
}
49+
if len(value)+1 > cap(buf)-len(buf) {
50+
_, _ = io.Discard.Write(buf)
51+
buf = buf[:0]
52+
}
53+
buf = append(buf, value...)
54+
buf = append(buf, '\n')
55+
}
56+
return nil
57+
},
58+
)
59+
require.NoError(b, err)
60+
}
61+
}
62+
63+
// BenchmarkQueryRows reads the result through database/sql.
64+
func BenchmarkQueryRows(b *testing.B) {
65+
conn, buf := setupChunkBench(b)
66+
67+
b.ReportAllocs()
68+
for b.Loop() {
69+
buf = buf[:0]
70+
rows, err := conn.QueryContext(context.Background(), benchChunkQuery)
71+
require.NoError(b, err)
72+
73+
var value string
74+
for rows.Next() {
75+
if err := rows.Scan(&value); err != nil {
76+
b.Fatal(err)
77+
}
78+
if len(value)+1 > cap(buf)-len(buf) {
79+
_, _ = io.Discard.Write(buf)
80+
buf = buf[:0]
81+
}
82+
buf = append(buf, value...)
83+
buf = append(buf, '\n')
84+
}
85+
require.NoError(b, rows.Err())
86+
require.NoError(b, rows.Close())
87+
}
88+
}

0 commit comments

Comments
 (0)