fix: thread-local CRDT and content store for test isolation
Tests shared a global CRDT singleton and content store HashMap, causing flaky failures when parallel tests wrote items that polluted each other's assertions. 3-5 random test failures per run. Both CRDT_STATE and CONTENT_STORE now use thread_local! in test mode so each test thread gets its own isolated instance. Production code is unchanged — it still uses the global OnceLock singletons. Also fixed 3 tests (create_story_writes_correct_content, next_item_number_increments_from_existing_bugs, next_item_number_scans_archived_too) that relied on leaked state from other tests — they now write to the content store explicitly. Result: 1902 passed, 0 failed across 5 consecutive runs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+44
-8
@@ -45,9 +45,38 @@ static PIPELINE_DB: OnceLock<PipelineDb> = OnceLock::new();
|
||||
|
||||
static CONTENT_STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static CONTENT_STORE_TL: OnceLock<Mutex<HashMap<String, String>>> = const { OnceLock::new() };
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn get_content_store() -> Option<&'static Mutex<HashMap<String, String>>> {
|
||||
CONTENT_STORE.get()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_content_store() -> Option<&'static Mutex<HashMap<String, String>>> {
|
||||
let tl = CONTENT_STORE_TL.with(|lock| {
|
||||
if lock.get().is_some() {
|
||||
Some(lock as *const OnceLock<Mutex<HashMap<String, String>>>)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(ptr) = tl {
|
||||
// SAFETY: The thread-local lives as long as the thread, which outlives
|
||||
// any test using it. We only need 'static for the return type.
|
||||
let lock = unsafe { &*ptr };
|
||||
lock.get()
|
||||
} else {
|
||||
CONTENT_STORE.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the full markdown content of a story from the in-memory store.
|
||||
pub fn read_content(story_id: &str) -> Option<String> {
|
||||
let store = CONTENT_STORE.get()?;
|
||||
let store = get_content_store()?;
|
||||
let map = store.lock().ok()?;
|
||||
map.get(story_id).cloned()
|
||||
}
|
||||
@@ -56,14 +85,14 @@ pub fn read_content(story_id: &str) -> Option<String> {
|
||||
///
|
||||
/// Updates the in-memory store immediately.
|
||||
pub fn write_content(story_id: &str, content: &str) {
|
||||
if let Some(store) = CONTENT_STORE.get() && let Ok(mut map) = store.lock() {
|
||||
if let Some(store) = get_content_store() && let Ok(mut map) = store.lock() {
|
||||
map.insert(story_id.to_string(), content.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a story's content from the in-memory store.
|
||||
pub fn delete_content(story_id: &str) {
|
||||
if let Some(store) = CONTENT_STORE.get() && let Ok(mut map) = store.lock() {
|
||||
if let Some(store) = get_content_store() && let Ok(mut map) = store.lock() {
|
||||
map.remove(story_id);
|
||||
}
|
||||
}
|
||||
@@ -72,16 +101,23 @@ pub fn delete_content(story_id: &str) {
|
||||
///
|
||||
/// Safe to call multiple times — the `OnceLock` is set at most once.
|
||||
pub fn ensure_content_store() {
|
||||
let _ = CONTENT_STORE.set(Mutex::new(HashMap::new()));
|
||||
// In tests, also initialise the in-memory CRDT state so that
|
||||
// write_item_with_content() and read_all_typed() work without async SQLite.
|
||||
#[cfg(not(test))]
|
||||
{ let _ = CONTENT_STORE.set(Mutex::new(HashMap::new())); }
|
||||
|
||||
#[cfg(test)]
|
||||
crate::crdt_state::init_for_test();
|
||||
{
|
||||
CONTENT_STORE_TL.with(|lock| {
|
||||
if lock.get().is_none() {
|
||||
let _ = lock.set(Mutex::new(HashMap::new()));
|
||||
}
|
||||
});
|
||||
crate::crdt_state::init_for_test();
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all story IDs present in the content store.
|
||||
pub fn all_content_ids() -> Vec<String> {
|
||||
match CONTENT_STORE.get() {
|
||||
match get_content_store() {
|
||||
Some(store) => match store.lock() {
|
||||
Ok(map) => map.keys().cloned().collect(),
|
||||
Err(_) => Vec::new(),
|
||||
|
||||
Reference in New Issue
Block a user