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
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:
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:
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::AsyncNoTracing. 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: