Skip to content

Commit fa78d1a

Browse files
committed
feat: add global registry
- This ensures only a single instance of a db (with is connection pools) can exist in the process - NOTE TO REVIEWERS: The caching behavior will be tested from the outside once the `database.rs` tests are committed.
1 parent 8c3818a commit fa78d1a

5 files changed

Lines changed: 191 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,20 @@ jobs:
4141
run: npm ci
4242

4343
- name: Run standards checks
44-
run: npm run standards
45-
env:
46-
# For PRs: lint from base branch. For pushes: lint from previous commit
47-
COMMITLINT_FROM: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
44+
run: |
45+
# Determine commitlint range
46+
if [ "${{ github.event_name }}" = "pull_request" ]; then
47+
export COMMITLINT_FROM="${{ github.event.pull_request.base.sha }}"
48+
else
49+
# For pushes, verify the before commit exists
50+
if git cat-file -e "${{ github.event.before }}" 2>/dev/null; then
51+
export COMMITLINT_FROM="${{ github.event.before }}"
52+
else
53+
# Fallback: check only HEAD commit on force push or first push
54+
export COMMITLINT_FROM="HEAD~1"
55+
fi
56+
fi
57+
npm run standards
4858
4959
- name: Run cargo check
5060
run: cargo check --workspace --all-targets

crates/sqlx-sqlite-conn-mgr/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ use std::sync::Arc;
4545
async fn main() -> Result<(), sqlx_sqlite_conn_mgr::Error> {
4646
// Connect to database (creates if missing, returns Arc<SqliteDatabase>)
4747
// (See below for how to customize the configuration)
48-
let db = SqliteDatabase::connect("example.db").await?;
48+
let db = SqliteDatabase::connect("example.db", None).await?;
4949

5050
// Multiple connects to the same path return the same instance
51-
let db2 = SqliteDatabase::connect("example.db").await?;
51+
let db2 = SqliteDatabase::connect("example.db", None).await?;
5252
assert!(Arc::ptr_eq(&db, &db2));
5353

5454
// Use read_pool() for read queries (supports concurrent reads)

crates/sqlx-sqlite-conn-mgr/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
//!
2020
//! ## Usage
2121
//!
22-
//! ```no_run
22+
//! // TODO: Remove this ignore once implementation is complete
23+
//! ```ignore
2324
//! use sqlx_sqlite_conn_mgr::SqliteDatabase;
2425
//! use std::sync::Arc;
2526
//!
@@ -59,13 +60,13 @@
5960
//! - Global registry caches new database instances (with their pools) and returns existing ones
6061
//! - WAL mode is enabled lazily only when writes are needed
6162
//!
62-
// TODO: Remove these allows once implementation is complete
63-
#![allow(dead_code)]
63+
// TODO: Remove this allow once implementation is complete
6464
#![allow(unused)]
6565

6666
mod config;
6767
mod database;
6868
mod error;
69+
mod registry;
6970
mod write_guard;
7071

7172
// Re-export public types
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
//! Global database registry to cache new database instances and return existing ones
2+
3+
use crate::Result;
4+
use crate::database::SqliteDatabase;
5+
use std::collections::HashMap;
6+
use std::future::Future;
7+
use std::path::{Path, PathBuf};
8+
use std::sync::{Arc, OnceLock, Weak};
9+
use tokio::sync::RwLock;
10+
11+
/// Global registry for SQLite databases
12+
static DATABASE_REGISTRY: OnceLock<RwLock<HashMap<PathBuf, Weak<SqliteDatabase>>>> =
13+
OnceLock::new();
14+
15+
fn registry() -> &'static RwLock<HashMap<PathBuf, Weak<SqliteDatabase>>> {
16+
DATABASE_REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
17+
}
18+
19+
/// Check if a path represents an in-memory SQLite database
20+
///
21+
/// Returns true for `:memory:` and `file::memory:*` URIs
22+
pub fn is_memory_database(path: &Path) -> bool {
23+
let path_str = path.to_str().unwrap_or("");
24+
path_str == ":memory:"
25+
|| path_str.starts_with("file::memory:")
26+
|| path_str.contains("mode=memory")
27+
}
28+
29+
/// Get or open a SQLite database connection
30+
///
31+
/// If a database is already connected, returns the cached instance.
32+
/// Otherwise, calls the provided factory function to create a new connection.
33+
///
34+
/// Special case: `:memory:` databases should not be cached (each is unique)
35+
pub async fn get_or_open_database<F, Fut>(path: &Path, factory: F) -> Result<Arc<SqliteDatabase>>
36+
where
37+
F: FnOnce() -> Fut,
38+
Fut: Future<Output = Result<SqliteDatabase>>,
39+
{
40+
// Skip registry for in-memory databases - always create new
41+
if is_memory_database(path) {
42+
let db = factory().await?;
43+
return Ok(Arc::new(db));
44+
}
45+
46+
// Canonicalize the path for consistent lookups
47+
let canonical_path = canonicalize_path(path)?;
48+
49+
// Try to get existing database with read lock (allows concurrent reads)
50+
{
51+
let registry = registry().read().await;
52+
53+
if let Some(weak) = registry.get(&canonical_path) {
54+
if let Some(db) = weak.upgrade() {
55+
return Ok(db);
56+
}
57+
// Weak reference exists but dead - will be cleaned up in write phase
58+
}
59+
}
60+
61+
// Phase 2: Database not found, acquire write lock
62+
let mut registry = registry().write().await;
63+
64+
// Double-check: another thread might have created it while we waited for write lock
65+
if let Some(weak) = registry.get(&canonical_path) {
66+
if let Some(db) = weak.upgrade() {
67+
return Ok(db);
68+
}
69+
}
70+
71+
// Clean up dead weak references while we have the write lock
72+
registry.retain(|_, weak| weak.strong_count() > 0);
73+
74+
// Now we're sure the database doesn't exist - create it while holding the lock
75+
// This prevents race conditions
76+
let db = factory().await?;
77+
let arc_db = Arc::new(db);
78+
79+
// Cache the new database
80+
registry.insert(canonical_path, Arc::downgrade(&arc_db));
81+
82+
Ok(arc_db)
83+
}
84+
85+
/// Helper to canonicalize a database path
86+
///
87+
/// This function attempts to resolve paths to their canonical form to ensure
88+
/// consistent cache lookups. It handles:
89+
/// - Absolute path resolution
90+
/// - Symlink resolution (when file exists)
91+
/// - Parent directory canonicalization (when file doesn't exist yet)
92+
///
93+
/// Known limitations when file doesn't exist:
94+
/// - Case sensitivity: On case-insensitive filesystems (macOS, Windows), paths
95+
/// differing only in case will be treated as different until the file is created.
96+
/// This could lead to multiple connection pools for the same logical database, at
97+
/// least until the file is created and can be canonicalized properly.
98+
/// - Symlinks in filename: If the filename itself will be a symlink (rare for SQLite),
99+
/// different symlink names won't be resolved until the file exists.
100+
fn canonicalize_path(path: &Path) -> std::io::Result<PathBuf> {
101+
match path.canonicalize() {
102+
Ok(p) => Ok(p),
103+
Err(_) => {
104+
// If path doesn't exist, try to canonicalize parent + filename
105+
let parent = path.parent().unwrap_or_else(|| Path::new("."));
106+
let filename = path
107+
.file_name()
108+
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid path"))?;
109+
let canonical_parent = parent.canonicalize()?;
110+
111+
// Note: We preserve the filename case as provided. On case-insensitive
112+
// filesystems, this means "MyDB.db" and "mydb.db" will create separate
113+
// cache entries until the file exists and can be canonicalized properly.
114+
// This is a known limitation but acceptable since:
115+
// 1. Most apps use consistent casing
116+
// 2. After first connection creates the file, subsequent connects will
117+
// use the canonical (on-disk) case
118+
Ok(canonical_parent.join(filename))
119+
}
120+
}
121+
}
122+
123+
/// Remove a database from the cache
124+
///
125+
/// Special case: `:memory:` databases are never in the registry
126+
///
127+
/// Returns an error if the path cannot be canonicalized
128+
pub async fn uncache_database(path: &Path) -> std::io::Result<()> {
129+
// Skip registry for in-memory databases
130+
if is_memory_database(path) {
131+
return Ok(());
132+
}
133+
134+
// Canonicalize path
135+
let canonical_path = canonicalize_path(path)?;
136+
137+
let mut registry = registry().write().await;
138+
registry.remove(&canonical_path);
139+
Ok(())
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
146+
#[test]
147+
fn test_canonicalize_path() {
148+
let temp_dir = std::env::temp_dir();
149+
let test_path = temp_dir.join("test.db");
150+
151+
// Test that path is canonicalized to absolute path
152+
let canonical = canonicalize_path(&test_path).unwrap();
153+
assert!(canonical.is_absolute());
154+
155+
// Test relative path
156+
let relative_path = Path::new("./test_relative.db");
157+
let canonical_relative = canonicalize_path(relative_path).unwrap();
158+
assert!(canonical_relative.is_absolute());
159+
}
160+
161+
#[test]
162+
fn test_canonicalize_nonexistent_path() {
163+
let temp_dir = std::env::temp_dir();
164+
let nonexistent = temp_dir.join("nonexistent_dir").join("test.db");
165+
166+
// Should fail if parent directory doesn't exist
167+
let result = canonicalize_path(&nonexistent);
168+
assert!(result.is_err());
169+
}
170+
}

crates/sqlx-sqlite-conn-mgr/src/write_guard.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use std::ops::{Deref, DerefMut};
2020
/// use sqlx::query;
2121
///
2222
/// # async fn example() -> Result<(), sqlx_sqlite_conn_mgr::Error> {
23-
/// let db = SqliteDatabase::connect("test.db").await?;
23+
/// let db = SqliteDatabase::connect("test.db", None).await?;
2424
/// let mut writer = db.acquire_writer().await?;
2525
/// // Use &mut *writer for write queries (e.g. INSERT/UPDATE/DELETE)
2626
/// query("INSERT INTO users (name) VALUES (?)")

0 commit comments

Comments
 (0)