This document offers a guided tour through userver-sqlite
.
It describes the main design decisions, shows how the pieces fit together, and highlights the trade-offs we made to keep SQLite usable inside a coroutine-based, non-blocking service.
SQLite is a lightweight, embedded, relational database that supports ACID transactions and various journal modes, including WAL, Rollback, and Memory. However, it is inherently synchronous, posing challenges for asynchronous, high-performance applications. The main goal of the userver-sqlite
driver is to integrate SQLite into userver's asynchronous environment seamlessly, ensuring efficient resource utilization and preventing the main thread from blocking.
At the highest level, interaction with the database occurs through the Client class. This class abstracts the complexity of managing connections, preparing and executing statements, and handling transactions. The key components involved include:
Journal mode | Pools created | Parallelism |
---|---|---|
wal (default) | RO × N + RW × 1 | N readers + 1 writer |
delete , truncate , persist , memory | exclusive_rw (single connection) | Fully serialized |
Read-only service | RO × N only | Unlimited readers |
The pool strategy dynamically initializes based on configurations, providing flexibility and robustness against common SQLite locking errors.
Detailed Explanation
WAL (Write-Ahead Logging) Mode
: The default mode provides optimal concurrency by allowing multiple concurrent readers along with a single writer. The userver-sqlite driver leverages this by creating two separate connection pools: a read-only pool (RO) containing multiple connections and a read-write pool (RW) limited to a single connection. This prevents the common SQLite issue of "database is locked" errors by efficiently managing reader and writer contention.Rollback, Memory, Delete, Truncate, Persist Modes
: These modes do not provide the concurrency benefits of WAL mode. They inherently serialize database access, meaning that when a write operation occurs, it exclusively locks the entire database. To manage this behavior safely, the driver implements an exclusive_rw strategy, utilizing a single read-write connection to serialize all database access fully. This approach simplifies management and guarantees database consistency but limits parallelism.Read-Only Service: For scenarios requiring only read operations, the driver initializes a read-only connection pool with multiple connections (RO × N). This setup maximizes parallelism for read-intensive workloads, avoiding unnecessary overhead from managing write-enabled connections.
Each query or transaction executed through the client must specify an OperationType
. This acts as a hint to determine the appropriate connection pool to handle the request:
OperationType::kReadOnly
routes the query to the read-only connection pool, optimizing concurrency for read-heavy workloads.OperationType::kReadWrite
directs the query to the read-write connection pool, reserved primarily for write operations.The Connection
class encapsulates the sqlite3
native structure, which manages the database connection lifecycle. It includes an LRU cache for prepared statements, allowing efficient reuse by simply binding new parameters each time. Connections also maintain statistics on executed queries and transactions.
Queries are managed through variadic templates, enabling convenient parameter passing directly in method calls. For more structured data, the driver supports decomposing arguments from aggregates (tuples or structs) using Boost.Pfr
and std::apply
, enhancing usability and type safety.
ResultSet
- proxy object that designed to encapsulate SQLite's incremental retrieval model. It manages executing queries in a step-by-step manner using sqlite3_step
and provides convenient methods to retrieve results in various formats:
AsVector(RowTag)
AsVector(FieldTag)
AsSingleRow
AsSingleField
AsOptionalSingleRow
AsOptionalSingleField
All retrieval methods internally utilize a unified template-based extraction mechanism.
Given SQLite's synchronous nature, blocking operations (sqlite3_open
, sqlite3_close
, sqlite3_step
) are executed in a dedicated task processor (blocking_task_processor) using built-in engine::AsyncNoSpan
. This approach isolates potentially blocking calls from userver's main task processor, ensuring application responsiveness and performance.
It is important to note that this approach, while effective in isolating blocking operations, is not ideal. Small CPU-bound tasks may inadvertently be executed on the blocking processor, slightly impacting performance. However, this compromise was consciously chosen due to SQLite's inherent synchronous nature. SQLite officially recommends using the WAL journal mode for concurrency and asynchronous operations, as it significantly reduces blolocking and optimizes parallel read-write scenarios. Therefore, the driver strongly favors WAL mode for best performance and concurrency.
Transactions follow the RAII pattern, automatically rolling back on scope exit if not explicitly committed. Both Transaction
and Savepoint
classes provide interfaces identical to the main Client
class, simplifying transaction management: