#![cfg(feature = "sqlite")]
use autumn_web::config::DatabaseConfig;
use autumn_web::db::{RuntimeConnection, create_pool};
use autumn_web::hooks::Patch;
use autumn_web::reexports::{diesel, diesel_async, scoped_futures};
use diesel::{ExpressionMethods as _, QueryDsl as _};
use diesel_async::RunQueryDsl as _;
use diesel_async::pooled_connection::deadpool::Pool;
use scoped_futures::ScopedFutureExt as _;
type SqlitePool = Pool<RuntimeConnection>;
mod schema {
autumn_web::reexports::diesel::table! {
counters (id) {
id -> Int8,
value -> Int8,
lock_version -> Int8,
}
}
}
use schema::counters;
#[autumn_web::model]
pub struct Counter {
#[id]
pub id: i64,
pub value: i64,
#[lock_version]
pub lock_version: i64,
}
#[autumn_web::repository(Counter)]
pub trait CounterRepository {}
async fn boot_pool(db_path: &std::path::Path) -> SqlitePool {
let url = format!("sqlite://{}", db_path.display());
let config = DatabaseConfig {
url: Some(url),
primary_pool_size: Some(4),
..Default::default()
};
let pool: SqlitePool = create_pool(&config)
.expect("sqlite pool builds via build_sqlite_pool")
.expect("a url is configured");
{
let mut conn = pool.get().await.expect("checkout a sqlite connection");
diesel::sql_query(
"CREATE TABLE counters (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
value BIGINT NOT NULL DEFAULT 0, \
lock_version BIGINT NOT NULL DEFAULT 1\
)",
)
.execute(&mut *conn)
.await
.expect("create counters table");
}
pool
}
async fn locked_increment(
repo: &PgCounterRepository,
id: i64,
delta: i64,
hold_ms: u64,
) -> autumn_web::AutumnResult<()> {
repo.with_lock(id, move |record, conn| {
async move {
if hold_ms > 0 {
tokio::time::sleep(std::time::Duration::from_millis(hold_ms)).await;
}
diesel::update(counters::table.find(record.id))
.set(counters::value.eq(record.value + delta))
.execute(conn)
.await
.map_err(autumn_web::AutumnError::from)?;
Ok::<(), autumn_web::AutumnError>(())
}
.scope_boxed()
})
.await
}
#[tokio::test]
async fn concurrent_immediate_rmw_writers_serialize_without_lost_update() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let pool = boot_pool(&tmp.path().join("serialize.db")).await;
let repo = PgCounterRepository::with_pool_untracked(pool);
let base = repo.save(&NewCounter { value: 0 }).await.expect("seed row");
let a = locked_increment(&repo, base.id, 10, 150);
let b = async {
tokio::time::sleep(std::time::Duration::from_millis(40)).await;
locked_increment(&repo, base.id, 5, 0).await
};
let (ra, rb) = tokio::join!(a, b);
ra.expect("writer A commits");
rb.expect("writer B queues on busy_timeout and commits (no SQLITE_BUSY_SNAPSHOT)");
let final_row = repo
.find_by_id(base.id)
.await
.expect("find")
.expect("row exists");
assert_eq!(
final_row.value, 15,
"both writers' increments are reflected"
);
}
#[tokio::test]
async fn update_queued_behind_held_write_lock_commits() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let pool = boot_pool(&tmp.path().join("queued.db")).await;
let repo = PgCounterRepository::with_pool_untracked(pool);
let base = repo.save(&NewCounter { value: 1 }).await.expect("seed row");
let a = locked_increment(&repo, base.id, 100, 150);
let b = async {
tokio::time::sleep(std::time::Duration::from_millis(40)).await;
repo.update(
base.id,
&UpdateCounter {
value: Patch::Set(999),
lock_version: base.lock_version,
},
)
.await
};
let (ra, rb) = tokio::join!(a, b);
ra.expect("writer A commits");
let updated = rb.expect("update queues on busy_timeout and commits");
assert_eq!(updated.value, 999);
assert_eq!(updated.lock_version, 2, "update bumps lock_version");
let final_row = repo
.find_by_id(base.id)
.await
.expect("find")
.expect("row exists");
assert_eq!(final_row.value, 999);
assert_eq!(final_row.lock_version, 2);
}
#[tokio::test]
async fn single_writer_update_commits_and_bumps_lock_version() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let pool = boot_pool(&tmp.path().join("single.db")).await;
let repo = PgCounterRepository::with_pool_untracked(pool);
let base = repo.save(&NewCounter { value: 7 }).await.expect("seed row");
let updated = repo
.update(
base.id,
&UpdateCounter {
value: Patch::Set(42),
lock_version: base.lock_version,
},
)
.await
.expect("single-writer immediate update commits");
assert_eq!(updated.value, 42);
assert_eq!(updated.lock_version, 2);
}
#[tokio::test]
async fn errored_rmw_rolls_back_the_immediate_transaction() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let pool = boot_pool(&tmp.path().join("rollback.db")).await;
let repo = PgCounterRepository::with_pool_untracked(pool);
let base = repo.save(&NewCounter { value: 5 }).await.expect("seed row");
let outcome: autumn_web::AutumnResult<()> = repo
.with_lock(base.id, |record, conn| {
async move {
diesel::update(counters::table.find(record.id))
.set(counters::value.eq(record.value + 1000))
.execute(conn)
.await
.map_err(autumn_web::AutumnError::from)?;
Err(autumn_web::AutumnError::internal_server_error_msg(
"intentional rollback",
))
}
.scope_boxed()
})
.await;
assert!(outcome.is_err(), "the RMW closure returned Err");
let after = repo
.find_by_id(base.id)
.await
.expect("find")
.expect("row exists");
assert_eq!(after.value, 5, "the mutation was rolled back");
assert_eq!(after.lock_version, 1, "no lock_version bump on rollback");
}
#[tokio::test]
async fn with_lock_callback_can_open_nested_transactions_on_sqlite() {
use autumn_web::savepoint;
use scoped_futures::ScopedFutureExt as _;
let tmp = tempfile::TempDir::new().expect("temp dir");
let pool = boot_pool(&tmp.path().join("nested.db")).await;
let repo = PgCounterRepository::with_pool_untracked(pool);
let base = repo.save(&NewCounter { value: 0 }).await.expect("seed row");
let outcome: autumn_web::AutumnResult<()> = repo
.with_lock(base.id, move |record, conn| {
let row_id = record.id;
let base_value = record.value;
async move {
savepoint(conn, move |sp_conn| {
async move {
diesel::update(counters::table.find(row_id))
.set(counters::value.eq(base_value + 3))
.execute(sp_conn)
.await
.map_err(autumn_web::AutumnError::from)?;
Ok::<(), autumn_web::AutumnError>(())
}
.scope_boxed()
})
.await?;
autumn_web::__private::scoped_transaction(conn, move |tx_conn| {
async move {
diesel::update(counters::table.find(row_id))
.set(counters::value.eq(base_value + 10))
.execute(tx_conn)
.await
.map_err(autumn_web::AutumnError::from)?;
Ok::<(), autumn_web::AutumnError>(())
}
.scope_boxed()
})
.await?;
Ok::<(), autumn_web::AutumnError>(())
}
.scope_boxed()
})
.await;
outcome.expect(
"a with_lock callback opening a nested savepoint/transaction succeeds on SQLite (parity \
with Postgres) — no \"cannot start a transaction within a transaction\"",
);
let final_row = repo
.find_by_id(base.id)
.await
.expect("find")
.expect("row exists");
assert_eq!(
final_row.value, 10,
"the nested-savepoint + nested-transaction writes committed with the outer immediate txn"
);
}
#[tokio::test]
async fn commit_failure_leaves_pool_yielding_reusable_connection() {
use diesel_async::SimpleAsyncConnection as _;
let tmp = tempfile::TempDir::new().expect("temp dir");
let url = format!("sqlite://{}", tmp.path().join("commit_fail.db").display());
let config = DatabaseConfig {
url: Some(url),
primary_pool_size: Some(1),
..Default::default()
};
let pool: SqlitePool = create_pool(&config)
.expect("sqlite pool builds")
.expect("a url is configured");
{
let mut conn = pool.get().await.expect("checkout a sqlite connection");
conn.batch_execute(
"CREATE TABLE parent (id INTEGER PRIMARY KEY); \
CREATE TABLE child (\
id INTEGER PRIMARY KEY, \
parent_id INTEGER NOT NULL REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED\
);",
)
.await
.expect("create deferred-fk schema");
let doomed: autumn_web::AutumnResult<()> =
autumn_web::__private::scoped_immediate_transaction(&mut *conn, |c| {
async move {
diesel::sql_query("INSERT INTO child (id, parent_id) VALUES (1, 999)")
.execute(c)
.await
.map_err(autumn_web::AutumnError::from)?;
Ok::<(), autumn_web::AutumnError>(())
}
.scope_boxed()
})
.await;
assert!(
doomed.is_err(),
"a deferred-FK violation must fail the transaction at COMMIT"
);
}
let mut conn2 = pool
.get()
.await
.expect("checkout after the failed COMMIT yields a usable connection");
#[derive(diesel::QueryableByName)]
struct CountRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
n: i64,
}
let child_rows = diesel::sql_query("SELECT COUNT(*) AS n FROM child")
.load::<CountRow>(&mut *conn2)
.await
.expect("count child rows on the reused connection");
assert_eq!(
child_rows.into_iter().next().map(|r| r.n),
Some(0),
"the failed COMMIT rolled back — no partial child rows persisted"
);
let reused: autumn_web::AutumnResult<i64> =
autumn_web::__private::scoped_immediate_transaction(&mut *conn2, |c| {
async move {
diesel::sql_query("INSERT INTO parent (id) VALUES (1)")
.execute(c)
.await
.map_err(autumn_web::AutumnError::from)?;
Ok::<i64, autumn_web::AutumnError>(1)
}
.scope_boxed()
})
.await;
assert_eq!(
reused.expect("the pool's connection is reusable after the COMMIT failure"),
1
);
}