Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Run Rust tests:
cargo test
```

### Linting and standards checks
### Linting and Standards Checks

```bash
npm run standards
Expand Down Expand Up @@ -109,6 +109,51 @@ fn main() {
}
```

### Tracing and Logging

This plugin and its connection manager crate use the
[`tracing`](https://crates.io/crates/tracing) ecosystem for internal logging. They are
configured with the `release_max_level_off` feature so that **all log statements are
compiled out of release builds**. This guarantees that logging from this plugin will never
reach production binaries unless you explicitly change that configuration.

To see logs during development, initialize a `tracing-subscriber` in your Tauri
application crate and keep it behind a `debug_assertions` guard, for example:

```toml
[dependencies]
tracing = { version = "0.1.41", default-features = false, features = ["std", "release_max_level_off"] }
tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"] }
```

```rust
#[cfg(debug_assertions)]
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};

let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("trace"));

fmt().with_env_filter(filter).compact().init();
}

#[cfg(not(debug_assertions))]
fn init_tracing() {}

fn main() {
init_tracing();

tauri::Builder::default()
.plugin(tauri_plugin_sqlite::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```

With this setup, `tauri dev` shows all plugin and app logs, while `tauri build` produces
a release binary that contains no logging from this plugin or your app-level `tracing`
calls.

### JavaScript/TypeScript API

Install the JavaScript package in your frontend:
Expand Down
1 change: 1 addition & 0 deletions crates/sqlx-sqlite-conn-mgr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ rust-version = "1.89"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] }
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["full"] }
tracing = { version = "0.1.41", default-features = false, features = ["std", "release_max_level_off"] }
34 changes: 34 additions & 0 deletions crates/sqlx-sqlite-conn-mgr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,32 @@ queries.
The operation is idempotent and safe to call across multiple sessions,
allowing concurrent reads during writes.

**Synchronous Mode: NORMAL vs FULL**

When WAL mode is enabled, this library sets `PRAGMA synchronous = NORMAL`
instead of `FULL` for the following reasons:

* **Performance**: `NORMAL` provides significantly better write performance
(up to 2-3x faster) by reducing the number of fsync operations. With `FULL`,
SQLite syncs after every checkpoint; with `NORMAL`, it syncs only the WAL file.

* **Safety in WAL mode**: `NORMAL` is safe in WAL mode because:
* WAL transactions are atomic and durable at the WAL file level
* The database file itself can be checkpointed asynchronously
* A crash may corrupt the database file, but the WAL file remains intact
and will be used to recover on next open
* This is different from rollback journal mode where `NORMAL` could cause
corruption

* **Mobile/Desktop Context**: For typical desktop and mobile applications,
`NORMAL` provides the best balance of performance and safety. `FULL` is
primarily needed for scenarios with unreliable storage hardware or when
power loss can occur mid-fsync operation.

See [SQLite WAL Performance Considerations][wal-perf] for more details.

[wal-perf]: https://www.sqlite.org/wal.html#performance_considerations

4. **Exclusive Writes**: The write pool has `max_connections=1`, ensuring
only one writer can exist at a time. Other callers to
`acquire_writer()` will block (asynchronously) until the current writer
Expand All @@ -154,6 +180,14 @@ queries.
* Idle timeout: 30 seconds by default (configurable via `custom_config`)
* Only customize `SqliteDatabaseConfig` when defaults don't meet your needs

## Tracing and Logging

This crate uses the [`tracing`](https://crates.io/crates/tracing) ecosystem for internal
instrumentation. It is built with the `release_max_level_off` feature so that all
`tracing` log statements are compiled out of release builds. To see its logs during
development, the host application must install a `tracing-subscriber` and enable the
desired log level; no extra configuration is required in this crate.

## Error Handling

```rust
Expand Down
39 changes: 28 additions & 11 deletions crates/sqlx-sqlite-conn-mgr/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ use sqlx::{ConnectOptions, Pool, Sqlite};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::error;

/// SQLite database with connection pooling for concurrent reads and optional exclusive writes.
///
/// The database is opened in read-write mode but can be used for read-only operations
/// by calling `read_pool()`. Write operations are available by calling `acquire_writer()`
/// which lazily initializes WAL mode on first use.
/// Once the database is opened it can be used for read-only operations by calling `read_pool()`.
/// Write operations are available by calling `acquire_writer()` which lazily initializes WAL mode
/// on first use.
///
/// # Example
///
Expand Down Expand Up @@ -66,7 +67,7 @@ impl SqliteDatabase {
/// If the database is already connected, returns the existing connection.
/// Multiple calls with the same path will return the same database instance.
///
/// The database is created if it doesn't exist. WAL mode is optionally enabled when
/// The database is created if it doesn't exist. WAL mode is enabled when
/// `acquire_writer()` is first called.
///
/// # Arguments
Expand Down Expand Up @@ -131,7 +132,8 @@ impl SqliteDatabase {
// Why do we need to manually create the database file? We could just let the connection
// create it if it doesn't exist, using `create_if_missing(true)`, right? Not if we called
// connect and then our very first query was a read-only query, like `PRAGMA user_version;`,
// for example. That would fail because the read pool cannot create the file
// for example. That would fail because the read pool connections are read-only and cannot
// create the file
if !db_exists && !is_memory_database(&path) {
let create_options = SqliteConnectOptions::new()
.filename(&path)
Expand Down Expand Up @@ -174,7 +176,7 @@ impl SqliteDatabase {
.await
}

/// Get a reference to the connection pool for executing SELECT queries
/// Get a reference to the connection pool for executing read queries
///
/// Use this for concurrent read operations. Multiple readers can access
/// the pool simultaneously.
Expand Down Expand Up @@ -240,6 +242,11 @@ impl SqliteDatabase {
.execute(&mut *conn)
.await?;

// https://www.sqlite.org/wal.html#performance_considerations
Comment thread
pmorris-dev marked this conversation as resolved.
sqlx::query("PRAGMA synchronous = NORMAL")
.execute(&mut *conn)
.await?;

self.wal_initialized.store(true, Ordering::SeqCst);
}

Expand Down Expand Up @@ -273,11 +280,7 @@ impl SqliteDatabase {

// Remove from registry
if let Err(e) = uncache_database(&self.path).await {
// TODO: Investigate use of "tracing" crate to log this error
#[cfg(debug_assertions)]
eprintln!("Failed to remove database from cache: {}", e);
#[cfg(not(debug_assertions))]
let _ = e; // Suppress unused variable warning
error!("Failed to remove database from cache: {}", e);
}

// This will await all readers to be returned
Expand Down Expand Up @@ -370,6 +373,7 @@ mod tests {
.fetch_one(db.read_pool().unwrap())
.await
.unwrap();

assert_eq!(count, 12);
}));
}
Expand Down Expand Up @@ -564,6 +568,17 @@ mod tests {
"Journal mode should be WAL after first acquire_writer"
);

// Check sync setting
let (sync,): (i32,) = sqlx::query_as("PRAGMA synchronous")
.fetch_one(&mut *writer)
.await
.unwrap();

assert_eq!(
sync, 1,
"Sync mode should be NORMAL after first acquire_writer"
);

drop(writer);

db.remove().await.unwrap();
Expand Down Expand Up @@ -676,6 +691,7 @@ mod tests {
.fetch_all(db_clone.read_pool().unwrap())
.await
.unwrap();

assert!(rows.len() > 0);
}));
}
Expand Down Expand Up @@ -703,6 +719,7 @@ mod tests {
.fetch_one(db.read_pool().unwrap())
.await
.unwrap();

assert_eq!(count.0, 2);

db.remove().await.unwrap();
Expand Down
2 changes: 1 addition & 1 deletion crates/sqlx-sqlite-conn-mgr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
//! - Uses sqlx's `SqlitePoolOptions` for all pool configuration
//! - Uses sqlx's `SqliteConnectOptions` for connection flags and configuration
//! - Minimal custom logic - delegates to sqlx wherever possible
//! - Global registry caches new database instances (with their pools) and returns existing ones
//! - Global registry caches new database instances and returns existing ones
//! - WAL mode is enabled lazily only when writes are needed
//!
mod config;
Expand Down