use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread::ThreadId;
use crate::database::db_context::DbContext;
use crate::database::hashmap_store::HashMapStore;
#[derive(Clone, Debug)]
struct Holder {
thread_id: ThreadId,
thread_name: Option<String>,
site: &'static str,
}
impl Holder {
fn describe(&self) -> String {
match &self.thread_name {
Some(name) => format!("{name:?} ({:?})", self.thread_id),
None => format!("<unnamed> ({:?})", self.thread_id),
}
}
}
static WRITERS: Mutex<Option<HashMap<usize, Holder>>> = Mutex::new(None);
#[must_use = "the slot is released when this drops — binding it to `_` drops it \
immediately and defeats the guard; bind it to a named local (e.g. \
`write_guard`) that lives for the whole write-transaction span"]
#[derive(Debug)]
pub struct WriteTransactionGuard {
key: usize,
#[allow(dead_code)]
store: Arc<HashMapStore>,
}
impl WriteTransactionGuard {
pub fn acquire(db_context: &DbContext, site: &'static str) -> anyhow::Result<Self> {
let store = Arc::clone(db_context.get_store());
let key = Arc::as_ptr(&store) as usize;
let current_thread = std::thread::current();
let current_thread_id = current_thread.id();
let current_thread_name = current_thread.name().map(str::to_owned);
let existing_holder = {
let mut writers = WRITERS.lock().unwrap_or_else(|e| e.into_inner());
let map = writers.get_or_insert_with(HashMap::new);
match map.get(&key) {
Some(holder) => Some(holder.clone()),
None => {
map.insert(
key,
Holder {
thread_id: current_thread_id,
thread_name: current_thread_name,
site,
},
);
None
}
}
};
if let Some(holder) = existing_holder {
let new_thread_desc = current_thread.name().map_or_else(
|| format!("{current_thread_id:?}"),
|n| format!("{n:?} ({current_thread_id:?})"),
);
let message = if holder.thread_id == current_thread_id {
format!(
"WriteTransactionGuard: `{site}` (thread {new_thread_desc}) tried to open a \
write transaction on a store THIS SAME THREAD already holds the slot for — \
acquired earlier at `{holder_site}` and never released. This is NOT a \
second concurrent writer; it is a re-entrant or retried `begin_transaction` \
on the same (or a nested) unit of work whose previous \
`WriteTransactionGuard` was never dropped — either a missing \
commit()/rollback() before the retry, or `self.write_guard = Some(new)` \
built `new` (running `acquire` again) before the old guard's `Drop` had a \
chance to run. Drop the previous guard (commit/rollback it, or explicitly \
`self.write_guard = None;`) before acquiring a new one.",
holder_site = holder.site,
)
} else {
format!(
"WriteTransactionGuard: a second write transaction started at `{site}` \
(thread {new_thread_desc}) while another was already open on this store, \
held by thread {holder} since `{holder_site}`. \
Transaction::begin_write_transaction takes a WHOLE-STORE savepoint \
(common/src/database/transactions.rs) — a second concurrent writer's \
rollback/Drop would silently erase the first writer's committed edits. \
Fix the caller; do not remove or weaken this guard.",
holder = holder.describe(),
holder_site = holder.site,
)
};
if cfg!(debug_assertions) {
panic!("{message}");
}
anyhow::bail!(message);
}
Ok(WriteTransactionGuard { key, store })
}
}
impl Drop for WriteTransactionGuard {
fn drop(&mut self) {
if let Some(map) = WRITERS.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
map.remove(&self.key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> DbContext {
DbContext::new().unwrap()
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
panic!("panic payload was neither &str nor String");
}
}
fn contention_message(db: &DbContext, site: &'static str) -> String {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
WriteTransactionGuard::acquire(db, site)
}));
match outcome {
Err(payload) => {
if !cfg!(debug_assertions) {
panic!(
"`{site}`: a release build must report contention by returning an \
error, not by panicking"
);
}
panic_message(payload.as_ref())
}
Ok(Err(error)) => {
if cfg!(debug_assertions) {
panic!(
"`{site}`: a debug build must report contention by panicking loudly \
at the offending call site, not by returning an error"
);
}
format!("{error}")
}
Ok(Ok(_)) => panic!(
"`{site}` acquired a slot that was already held — the guard did not fire \
at all, which is the exact silent failure it exists to prevent"
),
}
}
#[test]
fn sequential_acquire_release_never_trips_the_guard() {
let db = ctx();
for _ in 0..3 {
let g = WriteTransactionGuard::acquire(&db, "test").unwrap();
drop(g);
}
}
#[test]
fn two_different_stores_can_each_acquire_independently() {
let a = ctx();
let b = ctx();
let _ga = WriteTransactionGuard::acquire(&a, "a").unwrap();
let _gb = WriteTransactionGuard::acquire(&b, "b").unwrap();
}
#[test]
fn dropping_the_first_guard_frees_the_slot_for_a_second_acquire() {
let db = ctx();
let first = WriteTransactionGuard::acquire(&db, "first").unwrap();
drop(first);
let second = WriteTransactionGuard::acquire(&db, "second");
assert!(
second.is_ok(),
"the slot must be free once the first guard dropped"
);
}
#[test]
fn a_second_concurrent_acquire_on_the_same_store_fails() {
let db = ctx();
let _first = WriteTransactionGuard::acquire(&db, "first").unwrap();
let message = contention_message(&db, "second");
assert!(
message.contains("second"),
"message must name the call site that was refused: {message}"
);
let other = ctx();
assert!(WriteTransactionGuard::acquire(&other, "other").is_ok());
}
#[test]
fn the_guard_keeps_the_store_allocation_alive_after_the_db_context_drops() {
let db = ctx();
let store_ref = Arc::clone(db.get_store());
assert_eq!(Arc::strong_count(&store_ref), 2);
let guard = WriteTransactionGuard::acquire(&db, "test").unwrap();
assert_eq!(
Arc::strong_count(&store_ref),
3,
"WriteTransactionGuard::acquire must clone the store's Arc, not just read its pointer"
);
drop(db);
assert_eq!(
Arc::strong_count(&store_ref),
2,
"the guard must still be holding the store alive once the DbContext drops"
);
drop(guard);
assert_eq!(
Arc::strong_count(&store_ref),
1,
"the guard's clone must be released when the guard drops"
);
}
#[test]
fn cross_thread_contention_names_both_the_holder_and_the_new_claimant() {
let db = ctx();
let _first = WriteTransactionGuard::acquire(&db, "first_site_xyz").unwrap();
let db_for_thread = db.clone();
let handle = std::thread::Builder::new()
.name("contender-thread".to_string())
.spawn(move || contention_message(&db_for_thread, "second_site_xyz"))
.unwrap();
let message = handle
.join()
.expect("the contending thread must fail through the guard, not around it");
assert!(
message.contains("first_site_xyz"),
"message must name the current holder's call site: {message}"
);
assert!(
message.contains("second_site_xyz"),
"message must name the new claimant's call site: {message}"
);
assert!(
message.contains("contender-thread"),
"message must name the new claimant's thread: {message}"
);
assert!(
!message.contains("SAME THREAD"),
"a genuine cross-thread writer must not be misdiagnosed as same-thread \
re-entry: {message}"
);
}
#[test]
fn same_thread_reacquire_is_diagnosed_as_self_reentry_not_a_phantom_second_writer() {
let db = ctx();
let _first = WriteTransactionGuard::acquire(&db, "begin_transaction_first_call").unwrap();
let message = contention_message(&db, "begin_transaction_retry");
assert!(
message.contains("begin_transaction_first_call"),
"message must name the site that still holds the slot: {message}"
);
assert!(
message.contains("begin_transaction_retry"),
"message must name the re-entrant call site: {message}"
);
assert!(
message.contains("SAME THREAD") || message.contains("re-entrant"),
"message must say this is the same thread re-entering, not blame a second \
writer: {message}"
);
assert!(
!message.contains("Fix the caller; do not remove or weaken this guard"),
"must not reuse the generic cross-thread-second-writer wording for a \
same-thread self re-entry: {message}"
);
}
}