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:
@@ -91,6 +91,25 @@ describe("project-store-resolver", () => {
|
|||||||
expect(createdStores).toHaveLength(1);
|
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 () => {
|
it("creates separate stores for different projectIds", async () => {
|
||||||
const storeA = await getOrCreateProjectStore("proj_alpha");
|
const storeA = await getOrCreateProjectStore("proj_alpha");
|
||||||
const storeB = await getOrCreateProjectStore("proj_beta");
|
const storeB = await getOrCreateProjectStore("proj_beta");
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ import type { TaskStore } from "@fusion/core";
|
|||||||
*/
|
*/
|
||||||
const storeCache = new Map<string, TaskStore>();
|
const storeCache = new Map<string, TaskStore>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-flight creation promises, keyed by projectId.
|
||||||
|
* Prevents concurrent requests from creating duplicate store instances
|
||||||
|
* before the first creation completes and is added to storeCache.
|
||||||
|
*/
|
||||||
|
const pendingCreations = new Map<string, Promise<TaskStore>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track which stores have been fully initialized for real-time operation
|
* Track which stores have been fully initialized for real-time operation
|
||||||
* (watcher started). This prevents duplicate watch() calls on repeated
|
* (watcher started). This prevents duplicate watch() calls on repeated
|
||||||
@@ -38,6 +45,11 @@ const initializedProjects = new Set<string>();
|
|||||||
* engine agents) are detected and emitted as events.
|
* engine agents) are detected and emitted as events.
|
||||||
* - Subsequent calls: returns the cached instance immediately.
|
* - Subsequent calls: returns the cached instance immediately.
|
||||||
*
|
*
|
||||||
|
* Concurrent calls for the same projectId are deduplicated via a pending
|
||||||
|
* promise map, preventing the race condition where the SSE endpoint and
|
||||||
|
* an API mutation request both miss the cache and create separate store
|
||||||
|
* instances with independent EventEmitters.
|
||||||
|
*
|
||||||
* @param projectId - The central-registry project ID
|
* @param projectId - The central-registry project ID
|
||||||
* @returns A shared TaskStore instance for this project
|
* @returns A shared TaskStore instance for this project
|
||||||
*/
|
*/
|
||||||
@@ -47,19 +59,33 @@ export async function getOrCreateProjectStore(projectId: string): Promise<TaskSt
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { TaskStore: TaskStoreClass } = await import("@fusion/core");
|
// Deduplicate concurrent creation requests so SSE and API routes always
|
||||||
const store = await TaskStoreClass.getOrCreateForProject(projectId);
|
// share the same store instance even when both call this before the first
|
||||||
|
// creation completes.
|
||||||
// Start watching for external changes (CLI, engine agents, etc.)
|
const pending = pendingCreations.get(projectId);
|
||||||
// so SSE listeners receive live events even when mutations happen
|
if (pending) {
|
||||||
// outside this process.
|
return pending;
|
||||||
if (!initializedProjects.has(projectId)) {
|
|
||||||
initializedProjects.add(projectId);
|
|
||||||
await store.watch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storeCache.set(projectId, store);
|
const creation = (async () => {
|
||||||
return store;
|
const { TaskStore: TaskStoreClass } = await import("@fusion/core");
|
||||||
|
const store = await TaskStoreClass.getOrCreateForProject(projectId);
|
||||||
|
|
||||||
|
// Start watching for external changes (CLI, engine agents, etc.)
|
||||||
|
// so SSE listeners receive live events even when mutations happen
|
||||||
|
// outside this process.
|
||||||
|
if (!initializedProjects.has(projectId)) {
|
||||||
|
initializedProjects.add(projectId);
|
||||||
|
await store.watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
storeCache.set(projectId, store);
|
||||||
|
pendingCreations.delete(projectId);
|
||||||
|
return store;
|
||||||
|
})();
|
||||||
|
|
||||||
|
pendingCreations.set(projectId, creation);
|
||||||
|
return creation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +93,7 @@ export async function getOrCreateProjectStore(projectId: string): Promise<TaskSt
|
|||||||
* Useful for cleanup on project removal or server shutdown.
|
* Useful for cleanup on project removal or server shutdown.
|
||||||
*/
|
*/
|
||||||
export function evictProjectStore(projectId: string): void {
|
export function evictProjectStore(projectId: string): void {
|
||||||
|
pendingCreations.delete(projectId);
|
||||||
const store = storeCache.get(projectId);
|
const store = storeCache.get(projectId);
|
||||||
if (store) {
|
if (store) {
|
||||||
store.stopWatching();
|
store.stopWatching();
|
||||||
@@ -80,6 +107,7 @@ export function evictProjectStore(projectId: string): void {
|
|||||||
* Evict all cached stores. Used during server shutdown.
|
* Evict all cached stores. Used during server shutdown.
|
||||||
*/
|
*/
|
||||||
export function evictAllProjectStores(): void {
|
export function evictAllProjectStores(): void {
|
||||||
|
pendingCreations.clear();
|
||||||
for (const projectId of storeCache.keys()) {
|
for (const projectId of storeCache.keys()) {
|
||||||
evictProjectStore(projectId);
|
evictProjectStore(projectId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user