Skip to content

Commit 6b595b8

Browse files
authored
Merge pull request #38 from chdb-io/concurrent-connections
Support multiple concurrent connections per process
2 parents 33ae9f5 + a1746d7 commit 6b595b8

12 files changed

Lines changed: 937 additions & 111 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,28 @@ func main() {
131131
}
132132
```
133133

134+
#### Concurrency
135+
136+
chDB runs a single embedded engine per process bound to one data path, but that
137+
engine accepts multiple connections that execute queries concurrently. The
138+
`database/sql` driver opens an independent native chDB connection per pooled
139+
connection, so you can scale read/write parallelism with `SetMaxOpenConns`:
140+
141+
```go
142+
db, err := sql.Open("chdb", "session=/path/to/data")
143+
if err != nil {
144+
log.Fatal(err)
145+
}
146+
defer db.Close()
147+
148+
// Each pooled connection is its own native chDB connection to the same data
149+
// path, so queries run in parallel instead of serializing on one connection.
150+
db.SetMaxOpenConns(8)
151+
```
152+
153+
All connections in a process must share the same data path; opening a second,
154+
different data path while connections are still open returns an error.
155+
134156
### Golang API docs
135157

136158
- See [lowApi.md](lowApi.md) for the low level APIs.

chdb-purego/chdb.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,14 @@ func newChdbConn(conn *chdb_connection) ChdbConn {
112112
}
113113

114114
// Close implements ChdbConn.
115+
//
116+
// Close is idempotent: it nils the underlying handle after freeing it so a
117+
// second Close (or a Close racing a Query that checks for a nil handle) does
118+
// not double-free the native connection.
115119
func (c *connection) Close() {
116120
if c.conn != nil {
117121
chdbCloseConn(c.conn)
122+
c.conn = nil
118123
}
119124
}
120125

@@ -182,9 +187,10 @@ func (c *connection) Ready() bool {
182187
// - argc = 2, argv = []string{"--path=/tmp/chdb", "--readonly=1"}
183188
//
184189
// Important:
185-
// - There can be only one session at a time. If you want to create a new session, you need to close the existing one.
186-
// - Creating a new session will close the existing one.
187-
// - You need to ensure that the path exists before creating a new session. Or you can use NewConnectionFromConnString.
190+
// - chDB supports only one data path per process. Multiple connections to the
191+
// same path can be open at once and execute queries concurrently; connecting
192+
// to a different path while connections are still open returns an error.
193+
// - You need to ensure that the path exists before creating a new connection. Or you can use NewConnectionFromConnString.
188194
func NewConnection(argc int, argv []string) (ChdbConn, error) {
189195
var new_argv []string
190196
if (argc > 0 && argv[0] != "clickhouse") || argc == 0 {
@@ -274,8 +280,9 @@ func NewConnection(argc int, argv []string) (ChdbConn, error) {
274280
// - "mode=ro" would be "--readonly=1" for clickhouse (read-only mode)
275281
//
276282
// Important:
277-
// - There can be only one session at a time. If you want to create a new session, you need to close the existing one.
278-
// - Creating a new session will close the existing one.
283+
// - chDB supports only one data path per process. Multiple connections to the
284+
// same path can be open at once and execute queries concurrently; connecting
285+
// to a different path while connections are still open returns an error.
279286
func NewConnectionFromConnString(conn_string string) (ChdbConn, error) {
280287
if conn_string == "" || conn_string == ":memory:" {
281288
return NewConnection(0, []string{})

chdb-purego/stress_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,60 @@ func runStress(conn ChdbConn, id, depth int, queries, failures *atomic.Uint64, s
184184
queries.Add(1)
185185
}
186186
}
187+
188+
// TestMultiConnectionStress opens several connections to the same (in-memory)
189+
// data path and drives queries on all of them concurrently. It guards the
190+
// multi-connection path (refcounted EmbeddedServer + per-connection clients)
191+
// against crashes and regressions in the issue-#30 signal handling under
192+
// concurrent connect/query load. Skipped under `go test -short`.
193+
func TestMultiConnectionStress(t *testing.T) {
194+
if testing.Short() {
195+
t.Skip("skipping stress test in -short mode")
196+
}
197+
198+
const (
199+
duration = 5 * time.Second
200+
nConns = 4
201+
gPerConn = 4
202+
recurse = 16
203+
)
204+
205+
conns := make([]ChdbConn, 0, nConns)
206+
for i := 0; i < nConns; i++ {
207+
c, err := NewConnectionFromConnString(":memory:")
208+
if err != nil {
209+
t.Fatalf("connect %d: %v", i, err)
210+
}
211+
defer c.Close()
212+
conns = append(conns, c)
213+
}
214+
215+
var (
216+
wg sync.WaitGroup
217+
queries atomic.Uint64
218+
failures atomic.Uint64
219+
stop atomic.Bool
220+
)
221+
222+
for i := 0; i < nConns; i++ {
223+
for g := 0; g < gPerConn; g++ {
224+
wg.Add(1)
225+
go func(c ChdbConn) {
226+
defer wg.Done()
227+
runStress(c, 0, recurse, &queries, &failures, &stop)
228+
}(conns[i])
229+
}
230+
}
231+
232+
time.Sleep(duration)
233+
stop.Store(true)
234+
wg.Wait()
235+
236+
t.Logf("conns=%d goroutines=%d queries=%d failures=%d (%.0f qps)",
237+
nConns, nConns*gPerConn, queries.Load(), failures.Load(),
238+
float64(queries.Load())/duration.Seconds())
239+
240+
if failures.Load() != 0 {
241+
t.Fatalf("%d queries failed under multi-connection stress", failures.Load())
242+
}
243+
}

chdb.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import "github.com/chdb-io/chdb-go/chdb"
2828
func Query(queryStr string, outputFormats ...string) (result chdbpurego.ChdbResult, err error)
2929
```
3030

31-
Query calls query\_conn with a default in\-memory session and default output format of "CSV" if not provided.
31+
Query runs a one\-shot query and returns the materialized result \(default output format "CSV"\). chDB allows only one data path per process, so if a session is already open this helper attaches to that session's data path; otherwise it uses an in\-memory database.
3232

3333
<a name="QueryStream"></a>
3434
## func [QueryStream](<https://github.com/s0und0fs1lence/chdb-go/blob/main/chdb/wrapper.go#L23>)
@@ -37,7 +37,7 @@ Query calls query\_conn with a default in\-memory session and default output for
3737
func QueryStream(queryStr string, outputFormats ...string) (result chdbpurego.ChdbStreamResult, err error)
3838
```
3939

40-
Query calls query\_conn with a default in\-memory session and default output format of "CSV" if not provided.
40+
QueryStream is like Query but returns a streaming result that can be read in chunks, for large datasets that should not be fully materialized in memory. Like Query, it attaches to an already\-open session's data path, or uses an in\-memory database when none is open.
4141

4242
<a name="Session"></a>
4343
## type [Session](<https://github.com/s0und0fs1lence/chdb-go/blob/main/chdb/session.go#L14-L19>)
@@ -57,7 +57,7 @@ type Session struct {
5757
func NewSession(paths ...string) (*Session, error)
5858
```
5959

60-
NewSession creates a new session with the given path. If path is empty, a temporary directory is created. Note: The temporary directory is removed when Close is called.
60+
NewSession creates a new session with the given path. If path is empty, the session reuses an already\-open data path, or creates a temporary directory when none is open. Multiple sessions can be open at once as long as they share the same data path \(each owns an independent native connection, so they can run queries in parallel\); opening a session on a different path while another is still open returns an error. The temporary directory is removed when the last session using it is closed.
6161

6262
<a name="Session.Cleanup"></a>
6363
### func \(\*Session\) [Cleanup](<https://github.com/s0und0fs1lence/chdb-go/blob/main/chdb/session.go#L86>)

chdb/driver/driver.go

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -182,23 +182,43 @@ type connector struct {
182182
bufferSize int
183183
isStreaming bool
184184
useUnsafe bool
185-
session *chdb.Session
185+
connStr string
186+
keeper *chdb.Session
186187
}
187188

188189
// Connect returns a connection to a database.
189190
func (c *connector) Connect(ctx context.Context) (driver.Conn, error) {
190191
if c.driverType == INVALID {
191192
return nil, fmt.Errorf("DriverType not supported")
192193
}
194+
// Each database/sql connection gets its own native chDB connection to the
195+
// shared data path, so a pool of connections (MaxOpenConns > 1) yields real
196+
// parallel query execution instead of serializing on a single connection.
197+
session, err := chdb.NewSession(c.connStr)
198+
if err != nil {
199+
return nil, err
200+
}
193201
cc := &conn{
194-
udfPath: c.udfPath, session: c.session,
202+
udfPath: c.udfPath, session: session,
195203
driverType: c.driverType, bufferSize: c.bufferSize,
196204
useUnsafe: c.useUnsafe, isStreaming: c.isStreaming,
197205
}
198206
cc.SetupQueryFun()
199207
return cc, nil
200208
}
201209

210+
// Close releases the connector's keeper session. database/sql calls this from
211+
// DB.Close() because the connector implements io.Closer. Dropping the keeper
212+
// reference lets a registry-owned temp directory be removed once all pooled
213+
// connections are closed as well.
214+
func (c *connector) Close() error {
215+
if c.keeper != nil {
216+
c.keeper.Close()
217+
c.keeper = nil
218+
}
219+
return nil
220+
}
221+
202222
// Driver returns the underying Driver of the connector,
203223
// compatibility with the Driver method on sql.DB
204224
func (c *connector) Driver() driver.Driver { return Driver{} }
@@ -221,13 +241,6 @@ func parseConnectStr(str string) (ret map[string]string, err error) {
221241
}
222242
func NewConnect(opts map[string]string) (ret *connector, err error) {
223243
ret = &connector{}
224-
sessionPath, ok := opts[sessionOptionKey]
225-
if ok {
226-
ret.session, err = chdb.NewSession(sessionPath)
227-
if err != nil {
228-
return nil, err
229-
}
230-
}
231244
driverType, ok := opts[driverTypeKey]
232245
if ok {
233246
ret.driverType = parseDriverType(driverType)
@@ -256,13 +269,18 @@ func NewConnect(opts map[string]string) (ret *connector, err error) {
256269
if ok {
257270
ret.udfPath = udfPath
258271
}
259-
if ret.session == nil {
260272

261-
ret.session, err = chdb.NewSession()
262-
if err != nil {
263-
return nil, err
264-
}
273+
// Open a "keeper" session that pins the data path (and any temp directory)
274+
// for the lifetime of this connector. Each pooled connection then opens its
275+
// own session on the same path; the keeper guarantees the engine and temp
276+
// dir survive pool churn (when the live connection count briefly hits zero).
277+
sessionPath := opts[sessionOptionKey] // "" when not provided
278+
ret.keeper, err = chdb.NewSession(sessionPath)
279+
if err != nil {
280+
return nil, err
265281
}
282+
ret.connStr = ret.keeper.ConnStr()
283+
266284
ret.isStreaming = ret.driverType.SupportStreaming()
267285
return
268286
}
@@ -275,7 +293,25 @@ func (d Driver) Open(name string) (driver.Conn, error) {
275293
if err != nil {
276294
return nil, err
277295
}
278-
return cc.Connect(context.Background())
296+
c, err := cc.Connect(context.Background())
297+
if err != nil {
298+
// Connect failed; release the keeper session NewConnect just opened so
299+
// it does not leak (database/sql would normally own and close cc).
300+
if closer, ok := cc.(*connector); ok {
301+
_ = closer.Close()
302+
}
303+
return nil, err
304+
}
305+
// On the sql.Open path, database/sql keeps the connector and calls
306+
// connector.Close() on db.Close(). A direct Driver.Open caller discards the
307+
// connector, so tie the keeper session's lifetime to the returned
308+
// connection: closing the conn also releases the keeper.
309+
if cn, ok := c.(*conn); ok {
310+
if cnr, ok := cc.(*connector); ok {
311+
cn.connector = cnr
312+
}
313+
}
314+
return c, nil
279315
}
280316

281317
// OpenConnector expects the same format as driver.Open
@@ -294,6 +330,10 @@ type conn struct {
294330
useUnsafe bool
295331
isStreaming bool
296332
session *chdb.Session
333+
// connector is set only on the legacy Driver.Open path (not the sql.Open
334+
// path, where database/sql owns and closes the connector). When set, Close
335+
// also releases the connector's keeper session.
336+
connector *connector
297337

298338
QueryFun queryHandle
299339
streamFun queryStream
@@ -312,6 +352,16 @@ func prepareValues(values []driver.Value) []driver.NamedValue {
312352
}
313353

314354
func (c *conn) Close() error {
355+
if c.session != nil {
356+
c.session.Close()
357+
c.session = nil
358+
}
359+
// Only set on the Driver.Open path; releases the keeper that pins the data
360+
// path/temp dir for the connector.
361+
if c.connector != nil {
362+
_ = c.connector.Close()
363+
c.connector = nil
364+
}
315365
return nil
316366
}
317367

chdb/driver/driver_open_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package chdbdriver
2+
3+
import (
4+
"database/sql"
5+
"testing"
6+
7+
"github.com/chdb-io/chdb-go/chdb"
8+
)
9+
10+
// TestDriverOpenNoKeeperLeak verifies that the legacy Driver.Open path does not
11+
// leak the connector's keeper session. database/sql owns the connector on the
12+
// sql.Open path and calls connector.Close(), but a direct Driver.Open(name)
13+
// discards the connector, so closing the returned conn must release the keeper
14+
// too. Pre-fix the keeper session (a native connection + a registry refcount)
15+
// leaks on every Open call.
16+
func TestDriverOpenNoKeeperLeak(t *testing.T) {
17+
baseline := chdb.ActiveSessionRefs()
18+
19+
c, err := Driver{}.Open("session=" + session.ConnStr())
20+
if err != nil {
21+
t.Fatalf("Driver.Open failed: %s", err)
22+
}
23+
if err := c.Close(); err != nil {
24+
t.Fatalf("conn.Close failed: %s", err)
25+
}
26+
27+
if got := chdb.ActiveSessionRefs(); got != baseline {
28+
t.Fatalf("Driver.Open leaked sessions: refs=%d, baseline=%d (keeper not released on conn.Close)", got, baseline)
29+
}
30+
}
31+
32+
// TestDbCloseReleasesRefs verifies the database/sql path balances refcounts:
33+
// opening a *sql.DB, running queries across multiple pooled connections, and
34+
// closing it returns the registry to its baseline (keeper + per-conn sessions
35+
// all released).
36+
func TestDbCloseReleasesRefs(t *testing.T) {
37+
baseline := chdb.ActiveSessionRefs()
38+
39+
db, err := sql.Open("chdb", "session="+session.ConnStr())
40+
if err != nil {
41+
t.Fatalf("open db failed: %s", err)
42+
}
43+
db.SetMaxOpenConns(4)
44+
45+
for i := 0; i < 8; i++ {
46+
var n int
47+
if err := db.QueryRow("SELECT count() FROM numbers(10)").Scan(&n); err != nil {
48+
t.Fatalf("query failed: %s", err)
49+
}
50+
if n != 10 {
51+
t.Fatalf("got %d want 10", n)
52+
}
53+
}
54+
55+
if err := db.Close(); err != nil {
56+
t.Fatalf("db.Close failed: %s", err)
57+
}
58+
if got := chdb.ActiveSessionRefs(); got != baseline {
59+
t.Fatalf("db.Close left refs leaked: refs=%d, baseline=%d", got, baseline)
60+
}
61+
}

0 commit comments

Comments
 (0)