July 31, 2021·6 min read·engineeringrqlitegosqliteconcurrency

rqlite 6.0.2 gives SQLite connections explicit ownership

The July maintenance release moves the database layer to Go's database/sql package. Connection checkout, bounded pools, and scoped driver access address the multithreading problem.

Replicating writes through Raft does not solve every concurrency problem inside a node. A query, snapshot, or other database operation still needs valid access to the local SQLite connection. The July 31 release of rqlite 6.0.2 addresses what the project describes as a significant SQLite connection-handling and multithreading issue. The tagged changelog calls for all users to upgrade.

The principal implementation change is adoption of Go's database/sql package and connection pooling. This is a change to how rqlite owns and uses local connections, not a replacement for the consensus system. The July implementation also places an important bound on its new pool, an in-memory database has at most one connection. Pooling here should not be mistaken for an immediate promise of parallel in-memory queries.

I value rqlite's combination of SQLite and a small replicated service, but that composition needs clear ownership inside each process. This release is consequential because it makes connection access explicit at the point where different service activities reach the same local engine.

The database wrapper owns a pool

The database wrapper in the 6.0.2 tag stores a *sql.DB. Its opening function calls sql.Open and then Ping to check that the database can be used. Methods that execute or query statements obtain a *sql.Conn from that handle and defer its return through Close.

This distinguishes the long-lived database handle from a checked-out connection used for a particular operation. The pool remains available across calls. A query's scoped connection remains assigned while that method runs, so work performed through it has an explicit owner and return point.

The distinction matters in code review. A field with a connection-like name can represent a pool, a physical driver connection, or a request's exclusive checkout. The new implementation uses different types for those responsibilities. The shape of a method shows where it acquires access and when it relinquishes it.

The release does not require the HTTP application to become a connection manager. That responsibility belongs in the database wrapper. Callers can still ask it to execute a request, while its implementation chooses and retains the connection needed for that request's local work.

An operation retains its connection across a batch

Execute checks out one connection before iterating the request's statements. When the request asks for a transaction, it calls BeginTx on that connection and executes through the transaction object. Otherwise, it executes through the checked-out connection. Both paths keep the local statement work associated with the same checkout.

The error handling also depends on transaction scope. In the transactional path, a statement error causes a rollback and stops further processing. Outside that path, the method can record an error and continue with other statements. This behavior is visible in the source and makes the request's transaction flag more than a label in its JSON representation.

The query path has a similar structure. Query obtains a connection and passes it to queryWithConn. That helper can start a transaction when requested, execute statements, obtain column metadata, scan values, and return the assembled row results. The checkout remains alive while that work occurs.

The returned rows therefore have their own lifetime separate from the engine connection. The wrapper scans data into the response representation before returning it. A service response can outlive the local connection checkout because it contains copied result values, rather than an application holding an open driver cursor indefinitely.

Query processing also checks the iterator's final error after scanning rows. A statement can begin returning values and still encounter an error while iteration continues. The wrapper does not equate successful query creation with successful completion of the entire result. Its explicit rs.Err() check preserves that distinction before returning the assembled response.

Write processing separately obtains the last inserted identifier and affected-row count from each execution result. Errors in those calls go through the same request-aware error handler as execution errors. The response therefore depends on completing both the statement and the metadata extraction associated with it.

Pool size differs by storage mode

For file-backed databases, the tagged implementation allows up to 32 open connections and 32 idle connections. It sets a maximum idle time of 120 seconds and no maximum connection lifetime. These are implementation constants in this release. They are not new command-line tuning options advertised by the changelog.

The in-memory path has different settings. It permits one open connection and one idle connection, with no idle-time or lifetime expiration. The source explicitly says that in-memory databases do not support practical connection pooling in this implementation. A later explanation should not retroactively attribute another connection design to the July patch.

One connection can serve successive operations through the pool while preventing simultaneous checkouts from becoming simultaneous SQLite operations on that database handle. This is still useful lifecycle management. A pool limit of one expresses a constraint on access, even though it does not increase the amount of local concurrency available.

I regard that limit as an important part of the fix's description. A headline about adding a pool can sound like a throughput feature. The July release instead emphasizes a significant correctness issue, and its in-memory configuration deliberately constrains the number of active connections.

The file-backed limit also should not be read as a promise that 32 writes run in parallel. It bounds connection resources. What SQLite and the service can execute concurrently depends on the operations and their locking behavior. The source establishes the configured pool size, not a benchmark or universal throughput figure.

Driver-specific operations use scoped access

Some operations need capabilities below the generic SQL interface. Serialization is one example. The wrapper obtains a pooled connection, then calls its Raw method with a callback that accesses the SQLite driver connection. The callback serializes the database and returns an error if no byte representation is produced.

This keeps the driver-specific action inside a scoped use of a checked-out connection. The wrapper does not need to expose a permanently shared raw driver handle to every caller merely because one operation requires serialization. The same ownership pattern can coexist with ordinary SQL execution through ExecContext and QueryContext.

Deserialization has an additional sequence. The helper opens a temporary in-memory database, obtains a connection, and invokes the driver's deserialization method through scoped raw access. It then closes that connection before copying into another database. The source comment says testing showed this close was necessary.

Another comment explains the copy, the temporary database still refers to memory in Go space and needs to be copied into a new database. Connection scope and buffer lifetime are therefore related but separate responsibilities. Closing a connection does not by itself explain whether the database owns an independent copy of the deserialized content.

The copy helper obtains source and destination connections and releases both through deferred closes. It accesses the relevant driver operations within that arrangement. The release is consequently broader than replacing one call to a SQL method, database copying and serialization need the same ownership discipline as regular requests.

Status exposes what the pool is doing

The wrapper exports pool statistics including maximum open connections, current open connections, in-use and idle counts, and cumulative waiting information. It also reports counters for connections closed because of idle or lifetime limits. These values come from the standard library pool's statistics.

They describe local resource use. An in-use connection count does not establish Raft quorum health, and a pool wait does not itself identify a network delay. Separating those observations helps diagnose which layer is preventing progress. A request can wait for a local resource even when the cluster has already agreed on an operation.

The changelog also adds the current SQLite journal mode to status output. That supplies another piece of local database context alongside the pool measurements. Version 6.0.2 additionally updates SQLite to the 3.36 family and the rqlite driver to 1.20.4. Those dependency changes are separate entries from the connection-management redesign.

The substantive July change is a consistent path for acquiring and releasing local database access. Requests retain a checkout while executing, driver-specific operations run inside scoped access, and pool bounds distinguish file-backed from in-memory databases. The new statistics make those bounds and waits observable without conflating them with the cluster's consensus state.