fix: resolve race condition in project-store-resolver breaking real-time dashboard updates

Concurrent SSE and API requests for the same projectId both missed the
cache (storeCache.set ran after await store.watch()), creating independent
TaskStore instances with separate EventEmitters. SSE listeners attached
to one instance while mutations fired on the other, so no events reached
the browser.

Fix: add a pendingCreations promise map that deduplicates concurrent
calls, ensuring all callers share the same in-flight promise and thus
the same store instance. Also clear pendingCreations in evictProjectStore
and evictAllProjectStores. Adds a concurrent-call regression test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-03 07:08:47 -07:00
parent df73a82917
commit bed9fcb378
2 changed files with 58 additions and 11 deletions

View File

@@ -91,6 +91,25 @@ describe("project-store-resolver", () => {
expect(createdStores).toHaveLength(1);
});
it("deduplicates concurrent calls — concurrent SSE + API route requests share one store", async () => {
// Simulate the race: SSE endpoint and an API mutation both call
// getOrCreateProjectStore before either has set the cache.
const [store1, store2, store3] = await Promise.all([
getOrCreateProjectStore("proj_concurrent"),
getOrCreateProjectStore("proj_concurrent"),
getOrCreateProjectStore("proj_concurrent"),
]);
// All callers must receive the same instance so SSE and mutations share an EventEmitter
expect(store1).toBe(store2);
expect(store2).toBe(store3);
// Only one underlying store should have been created
expect(createdStores).toHaveLength(1);
// watch() called exactly once
expect(createdStores[0].watchMock).toHaveBeenCalledTimes(1);
});
it("creates separate stores for different projectIds", async () => {
const storeA = await getOrCreateProjectStore("proj_alpha");
const storeB = await getOrCreateProjectStore("proj_beta");