perf(db): reads no longer wait behind writes; pages answer from cache
A series page took about a second to show its seasons on a phone, every visit, although they were cached. Three things stacked up: - One SQLite connection behind one mutex served the whole app, so every read queued behind every write. The database now has one owner: a writer thread for writes and a pool of read-only WAL connections for reads. synchronous = NORMAL and a busy timeout on every connection. - The listing query built the set of every available item in the database before filtering to the parent (~80 ms on a desktop for a 100k-item cache), then fetched user data one row at a time. It now checks availability per row, uses the hierarchy indexes (1.5 ms on the same benchmark) and batches the user-data lookup. - A cache read that missed the 100 ms fast path was set aside until the server answered. It is now raced against the server; whichever answers first with content wins. On the Fairphone, Frasier's season and episode lists now come from cache in 34-133 ms (was 600-1030 ms waiting on the server). Fixes found on the way, each with a test that failed first: - sync_queue_mutation could return another mutation's row id: the id came from a second trip to the shared connection. insert() reads it in the same job. - save_to_cache switched foreign keys off on the shared connection across its awaits, so concurrent writes ran unchecked. The toggle now lives inside one writer job, and a page is one transaction instead of one commit per row. Also: thumbnail LRU touches no longer block the lookup; unused tokio-rusqlite dropped. Design and invariants in docs/architecture/08-database-design.md (Connection ownership, Listing query shape) and 03-data-flow.md.
This commit is contained in:
@@ -314,57 +314,22 @@ pub struct PlaybackModeManager {
|
||||
|
||||
**Location**: `src-tauri/src/storage/db_service.rs`
|
||||
|
||||
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
|
||||
Async database interface over `rusqlite`. `RusqliteService` owns the
|
||||
database: writes run as jobs on one writer thread, reads on a pool of read-only
|
||||
WAL connections, so a read never waits for a write. Callers only see the
|
||||
trait (`execute`, `insert`, `execute_detached`, `query_one` / `query_optional` /
|
||||
`query_many`, `transaction`, `transaction_without_foreign_keys`) and build
|
||||
queries with `Query` + `QueryParam`, which keeps values out of the SQL string.
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait DatabaseService: Send + Sync {
|
||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
|
||||
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
|
||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
|
||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
|
||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
|
||||
where F: Fn(&Row) -> Result<T> + Send + 'static;
|
||||
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
|
||||
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
|
||||
}
|
||||
|
||||
pub struct RusqliteService {
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl DatabaseService for RusqliteService {
|
||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
|
||||
let conn = self.connection.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Execute query on blocking thread pool
|
||||
}).await?
|
||||
}
|
||||
// ... other methods use spawn_blocking
|
||||
}
|
||||
```
|
||||
|
||||
**Key Benefits:**
|
||||
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
|
||||
- **Type Safety**: `QueryParam` enum prevents SQL injection
|
||||
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
|
||||
- **Testable**: Can mock DatabaseService for tests
|
||||
|
||||
**Usage Pattern:**
|
||||
```rust
|
||||
// Before (blocking - causes UI freeze)
|
||||
let conn = database.connection();
|
||||
let conn = conn.lock().unwrap(); // BLOCKS
|
||||
conn.query_row(...) // BLOCKS
|
||||
|
||||
// After (async - no freezing)
|
||||
let db_service = database.service();
|
||||
let db_service = database.service(); // cheap clone of the shared owner
|
||||
let query = Query::with_params("SELECT ...", vec![...]);
|
||||
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
|
||||
db_service.query_one(query, |row| {...}).await // runs on a reader
|
||||
```
|
||||
|
||||
Ownership model, invariants and the reasons for them:
|
||||
[08-database-design.md → Connection ownership](08-database-design.md#connection-ownership).
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```mermaid
|
||||
|
||||
Reference in New Issue
Block a user