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:
dave
2026-04-11 13:02:09 +00:00
parent dd53870c59
commit eea54ca616
4 changed files with 111 additions and 42 deletions
+44 -8
View File
@@ -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(),