fix(economy): BEGIN IMMEDIATE for write transactions (concurrency-safe)
Root cause of the fresh-DB multi-connection write failure: economy writes ran in a DEFERRED transaction (pool.begin() = BEGIN) that read then wrote; SQLite returns SQLITE_BUSY (code 5, 'database is locked') *immediately* when a deferred tx upgrades to a write while another holds the write lock, bypassing busy_timeout to avoid deadlock. Fix: open each write op with BEGIN IMMEDIATE on a dedicated pooled connection (finish() commits/rolls back), taking the write lock up front so busy_timeout serializes writers. Reproduction test: 100 fresh DBs x 8 concurrent grant_reward — was 644/800 failures, now 800/800 succeed with correct final balance (no lost update). Reads unchanged.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
//! Reproduction for the fresh-DB multi-connection warm-up write failure.
|
||||
//! Forces several pooled connections to open concurrently on a brand-new DB and
|
||||
//! captures the ACTUAL sqlx/SQLite error (not the service's generic string).
|
||||
|
||||
use openfut_core::db::{init_pool, run_migrations};
|
||||
use openfut_core::services::economy;
|
||||
|
||||
async fn seed_club(pool: &sqlx::SqlitePool) {
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p','t','t')",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('c','p','c',100000,'t','t')")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn fresh_db_multiconn_concurrent_writes() {
|
||||
let base = std::env::temp_dir().join(format!("ofut-cc-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let iters = 100usize;
|
||||
let mut failures = 0usize;
|
||||
let mut first_err = String::new();
|
||||
for i in 0..iters {
|
||||
let url = format!("sqlite://{}/db{i}.db", base.display());
|
||||
let pool = init_pool(&url, 5).await.expect("init_pool");
|
||||
run_migrations(&pool).await.expect("migrations");
|
||||
seed_club(&pool).await;
|
||||
// Fire concurrent credits to force several connections to warm up at once
|
||||
// on the brand-new DB, then a write — the harness's failing shape.
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let p = pool.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
economy::grant_reward(&p, "c", 1).await
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
match h.await.unwrap() {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
failures += 1;
|
||||
if first_err.is_empty() {
|
||||
first_err = format!("{e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Serialization correctness: 8 concurrent +1 credits, no lost update.
|
||||
let bal = economy::balance(&pool, "c").await.unwrap();
|
||||
assert_eq!(bal, 100_008, "iter {i}: lost update under concurrency");
|
||||
pool.close().await;
|
||||
}
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
assert_eq!(
|
||||
failures,
|
||||
0,
|
||||
"{failures}/{} iterations had a write failure; first error: {first_err}",
|
||||
iters * 8
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user