Skip to content

Commit 6af984e

Browse files
committed
add QueryBinary, an alloc-free way to read all rows into a buffer
WIP; goal is alloc-free reads of a query into a Go-provided buffer. Go code can then parse the simple binary format and alloc if needed (doing its own cache lookups, including alloc-free m[string([]byte)] lookups, and returning existing Views if data is unmodified) Signed-off-by: Brad Fitzpatrick <[email protected]>
1 parent 8a7a943 commit 6af984e

File tree

8 files changed

+422
-3
lines changed

8 files changed

+422
-3
lines changed

binary.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package sqlite
6+
7+
import (
8+
"context"
9+
"encoding/binary"
10+
"errors"
11+
"fmt"
12+
"math"
13+
"sync"
14+
15+
"github.com/tailscale/sqlite/sqliteh"
16+
"golang.org/x/sys/cpu"
17+
)
18+
19+
type driverConnRawCall struct {
20+
f func(driverConn any) error
21+
22+
// results
23+
dc *conn
24+
ok bool
25+
}
26+
27+
var driverConnRawCallPool = &sync.Pool{
28+
New: func() any {
29+
c := new(driverConnRawCall)
30+
c.f = func(driverConn any) error {
31+
c.dc, c.ok = driverConn.(*conn)
32+
return nil
33+
}
34+
return c
35+
},
36+
}
37+
38+
func getDriverConn(sc SQLConn) (dc *conn, ok bool) {
39+
c := driverConnRawCallPool.Get().(*driverConnRawCall)
40+
defer driverConnRawCallPool.Put(c)
41+
err := sc.Raw(c.f)
42+
if err != nil {
43+
return nil, false
44+
}
45+
return c.dc, c.ok
46+
}
47+
48+
func QueryBinary(ctx context.Context, sqlconn SQLConn, optScratch []byte, query string, args ...any) (BinaryResults, error) {
49+
c, ok := getDriverConn(sqlconn)
50+
if !ok {
51+
return nil, errors.New("sqlconn is not of expected type")
52+
}
53+
st, err := c.prepare(ctx, query, IsPersist(ctx))
54+
if err != nil {
55+
return nil, err
56+
}
57+
buf := optScratch
58+
if len(buf) == 0 {
59+
buf = make([]byte, 128)
60+
}
61+
for {
62+
st.stmt.ResetAndClear()
63+
// TODO(bradfitz): bind args
64+
n, err := st.stmt.StepAllBinary(buf)
65+
if err == nil {
66+
return BinaryResults(buf[:n]), nil
67+
}
68+
if e, ok := err.(sqliteh.BufferSizeTooSmallError); ok {
69+
buf = make([]byte, e.EncodedSize)
70+
continue
71+
}
72+
return nil, err
73+
}
74+
}
75+
76+
// BinaryResults is the result of QueryBinary.
77+
//
78+
// You should not depend on its specific format and parse it via its methods
79+
// instead.
80+
type BinaryResults []byte
81+
82+
type BinaryToken struct {
83+
StartRow bool
84+
EndRow bool
85+
EndRows bool
86+
IsInt bool // if so, use Int() method
87+
IsFloat bool // if so, use Float() method
88+
IsNull bool
89+
IsBytes bool
90+
Error bool
91+
92+
x uint64
93+
Bytes []byte
94+
}
95+
96+
func (t *BinaryToken) String() string {
97+
switch {
98+
case t.StartRow:
99+
return "start-row"
100+
case t.EndRow:
101+
return "end-row"
102+
case t.EndRows:
103+
return "end-rows"
104+
case t.IsNull:
105+
return "null"
106+
case t.IsInt:
107+
return fmt.Sprintf("int: %v", t.Int())
108+
case t.IsFloat:
109+
return fmt.Sprintf("float: %g", t.Float())
110+
case t.IsBytes:
111+
return fmt.Sprintf("bytes: %q", t.Bytes)
112+
case t.Error:
113+
return "error"
114+
default:
115+
return "unknown"
116+
}
117+
}
118+
119+
func (t *BinaryToken) Int() int64 { return int64(t.x) }
120+
func (t *BinaryToken) Float() float64 { return math.Float64frombits(t.x) }
121+
122+
func (r *BinaryResults) Next() BinaryToken {
123+
if len(*r) == 0 {
124+
return BinaryToken{Error: true}
125+
}
126+
first := (*r)[0]
127+
*r = (*r)[1:]
128+
switch first {
129+
default:
130+
return BinaryToken{Error: true}
131+
case '(':
132+
return BinaryToken{StartRow: true}
133+
case ')':
134+
return BinaryToken{EndRow: true}
135+
case 'E':
136+
return BinaryToken{EndRows: true}
137+
case 'n':
138+
return BinaryToken{IsNull: true}
139+
case 'i', 'f':
140+
if len(*r) < 8 {
141+
return BinaryToken{Error: true}
142+
}
143+
t := BinaryToken{IsInt: first == 'i', IsFloat: first == 'f'}
144+
if cpu.IsBigEndian {
145+
t.x = binary.BigEndian.Uint64((*r)[:8])
146+
} else {
147+
t.x = binary.LittleEndian.Uint64((*r)[:8])
148+
}
149+
*r = (*r)[8:]
150+
return t
151+
case 'b':
152+
if len(*r) < 8 {
153+
return BinaryToken{Error: true}
154+
}
155+
t := BinaryToken{IsBytes: true}
156+
var n int64
157+
if cpu.IsBigEndian {
158+
n = int64(binary.BigEndian.Uint64((*r)[:8]))
159+
} else {
160+
n = int64(binary.LittleEndian.Uint64((*r)[:8]))
161+
}
162+
*r = (*r)[8:]
163+
if int64(len(*r)) < n {
164+
return BinaryToken{Error: true}
165+
}
166+
t.Bytes = (*r)[:n]
167+
*r = (*r)[n:]
168+
return t
169+
}
170+
}

binary_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package sqlite
6+
7+
import (
8+
"context"
9+
"math"
10+
"reflect"
11+
"testing"
12+
13+
"github.com/google/go-cmp/cmp"
14+
)
15+
16+
func TestQueryBinary(t *testing.T) {
17+
ctx := WithPersist(context.Background())
18+
db := openTestDB(t)
19+
exec(t, db, "CREATE TABLE t (id INTEGER PRIMARY KEY, f REAL, txt TEXT, blb BLOB)")
20+
exec(t, db, "INSERT INTO t VALUES (?, ?, ?, ?)", math.MinInt64, 1.0, "text-a", "blob-a")
21+
exec(t, db, "INSERT INTO t VALUES (?, ?, ?, ?)", -1, -1.0, "text-b", "blob-b")
22+
exec(t, db, "INSERT INTO t VALUES (?, ?, ?, ?)", 0, 0, "text-c", "blob-c")
23+
exec(t, db, "INSERT INTO t VALUES (?, ?, ?, ?)", 20, 2, "text-d", "blob-d")
24+
exec(t, db, "INSERT INTO t VALUES (?, ?, ?, ?)", math.MaxInt64, nil, "text-e", "blob-e")
25+
exec(t, db, "INSERT INTO t VALUES (?, ?, ?, ?)", 42, 0.25, "text-f", nil)
26+
exec(t, db, "INSERT INTO t VALUES (?, ?, ?, ?)", 43, 1.75, "text-g", nil)
27+
28+
conn, err := db.Conn(ctx)
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
buf, err := QueryBinary(ctx, conn, make([]byte, 100), "SELECT * FROM t ORDER BY id")
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
t.Logf("Got %d bytes: %q", len(buf), buf)
38+
39+
var got []string
40+
iter := buf
41+
for len(iter) > 0 {
42+
t := iter.Next()
43+
got = append(got, t.String())
44+
if t.Error {
45+
break
46+
}
47+
}
48+
want := []string{
49+
"start-row", "int: -9223372036854775808", "float: 1", "bytes: \"text-a\"", "bytes: \"blob-a\"", "end-row",
50+
"start-row", "int: -1", "float: -1", "bytes: \"text-b\"", "bytes: \"blob-b\"", "end-row",
51+
"start-row", "int: 0", "float: 0", "bytes: \"text-c\"", "bytes: \"blob-c\"", "end-row",
52+
"start-row", "int: 20", "float: 2", "bytes: \"text-d\"", "bytes: \"blob-d\"", "end-row",
53+
"start-row", "int: 42", "float: 0.25", "bytes: \"text-f\"", "null", "end-row",
54+
"start-row", "int: 43", "float: 1.75", "bytes: \"text-g\"", "null", "end-row",
55+
"start-row", "int: 9223372036854775807", "null", "bytes: \"text-e\"", "bytes: \"blob-e\"", "end-row",
56+
"end-rows",
57+
}
58+
if !reflect.DeepEqual(got, want) {
59+
t.Errorf("wrong results\n got: %q\nwant: %q\n\ndiff:\n%s", got, want, cmp.Diff(want, got))
60+
}
61+
62+
allocs := int(testing.AllocsPerRun(10000, func() {
63+
_, err := QueryBinary(ctx, conn, buf, "SELECT * FROM t")
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
}))
68+
const maxAllocs = 5 // as of Go 1.20
69+
if allocs > maxAllocs {
70+
t.Errorf("allocs = %v; want max %v", allocs, maxAllocs)
71+
}
72+
}
73+
74+
func BenchmarkQueryBinaryParallel(b *testing.B) {
75+
ctx := WithPersist(context.Background())
76+
db := openTestDB(b)
77+
exec(b, db, "CREATE TABLE t (id INTEGER PRIMARY KEY, f REAL, txt TEXT, blb BLOB)")
78+
exec(b, db, "INSERT INTO t VALUES (?, ?, ?, ?)", 42, 0.25, "text-f", "some big big big big blob so big like so many bytes even")
79+
80+
b.ResetTimer()
81+
b.ReportAllocs()
82+
b.RunParallel(func(pb *testing.PB) {
83+
conn, err := db.Conn(ctx)
84+
if err != nil {
85+
b.Error(err)
86+
return
87+
}
88+
89+
var buf = make([]byte, 250)
90+
91+
for pb.Next() {
92+
res, err := QueryBinary(ctx, conn, buf, "SELECT id, f, txt, blb FROM t WHERE id=42")
93+
if err != nil {
94+
b.Error(err)
95+
return
96+
}
97+
t := res.Next()
98+
if !t.StartRow {
99+
b.Errorf("didn't get start row; got %v", t)
100+
return
101+
}
102+
t = res.Next()
103+
if t.Int() != 42 {
104+
b.Errorf("got %v; want 42", t)
105+
return
106+
}
107+
}
108+
})
109+
110+
}

cgosqlite/cgosqlite.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ package cgosqlite
5454
// #include "cgosqlite.h"
5555
import "C"
5656
import (
57+
"errors"
5758
"sync"
5859
"time"
5960
"unsafe"
@@ -120,6 +121,7 @@ type Stmt struct {
120121
// used as scratch space when calling into cgo
121122
rowid, changes C.sqlite3_int64
122123
duration C.int64_t
124+
encodedSize C.int
123125
}
124126

125127
// Open implements sqliteh.OpenFunc.
@@ -416,6 +418,23 @@ func (stmt *Stmt) ColumnDeclType(col int) string {
416418
return res
417419
}
418420

421+
func (stmt *Stmt) StepAllBinary(dstBuf []byte) (n int, err error) {
422+
if len(dstBuf) == 0 {
423+
return 0, errors.New("zero-length buffer to StepAllBinary")
424+
}
425+
ret := C.ts_sqlite_step_all(stmt.stmt.int(), (*C.char)(unsafe.Pointer(&dstBuf[0])), C.int(len(dstBuf)), &stmt.encodedSize)
426+
427+
if int(stmt.encodedSize) > len(dstBuf) {
428+
return 0, sqliteh.BufferSizeTooSmallError{
429+
EncodedSize: int(stmt.encodedSize),
430+
}
431+
}
432+
if err := errCode(ret); err != nil {
433+
return 0, err
434+
}
435+
return int(stmt.encodedSize), nil
436+
}
437+
419438
var emptyCStr = C.CString("")
420439

421440
func errCode(code C.int) error { return sqliteh.CodeAsError(sqliteh.Code(code)) }

0 commit comments

Comments
 (0)