diff --git a/src/router/config.ts b/src/router/config.ts index f83fcacc..a0c1c506 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -51,11 +51,16 @@ const PROJECT_CONFIG_TTL_MS = 5_000; let _projectConfigCache: { projects: RouterProjectConfig[]; fullProjects: ProjectConfig[] } | null = null; let _projectConfigExpiresAt = 0; +let _pendingConfigFetch: Promise<{ + projects: RouterProjectConfig[]; + fullProjects: ProjectConfig[]; +}> | null = null; /** @internal Visible for testing only */ export function _resetProjectConfigCache(): void { _projectConfigCache = null; _projectConfigExpiresAt = 0; + _pendingConfigFetch = null; } export async function loadProjectConfig(): Promise<{ @@ -66,37 +71,43 @@ export async function loadProjectConfig(): Promise<{ return _projectConfigCache; } - const config: CascadeConfig = await loadConfig(); - const result = { - projects: config.projects.map((p) => { - const trelloConfig = getTrelloConfig(p); - const jiraConfig = getJiraConfig(p); - return { - id: p.id, - repo: p.repo, - pmType: p.pm?.type ?? 'trello', - ...(trelloConfig && { - trello: { - boardId: trelloConfig.boardId, - lists: trelloConfig.lists, - labels: trelloConfig.labels, - }, - }), - ...(jiraConfig && { - jira: { - projectKey: jiraConfig.projectKey, - baseUrl: jiraConfig.baseUrl, - }, + if (!_pendingConfigFetch) { + _pendingConfigFetch = (async () => { + const config: CascadeConfig = await loadConfig(); + const result = { + projects: config.projects.map((p) => { + const trelloConfig = getTrelloConfig(p); + const jiraConfig = getJiraConfig(p); + return { + id: p.id, + repo: p.repo, + pmType: p.pm?.type ?? 'trello', + ...(trelloConfig && { + trello: { + boardId: trelloConfig.boardId, + lists: trelloConfig.lists, + labels: trelloConfig.labels, + }, + }), + ...(jiraConfig && { + jira: { + projectKey: jiraConfig.projectKey, + baseUrl: jiraConfig.baseUrl, + }, + }), + }; }), + fullProjects: config.projects, }; - }), - fullProjects: config.projects, - }; - - _projectConfigCache = result; - _projectConfigExpiresAt = Date.now() + PROJECT_CONFIG_TTL_MS; + _projectConfigCache = result; + _projectConfigExpiresAt = Date.now() + PROJECT_CONFIG_TTL_MS; + return result; + })().finally(() => { + _pendingConfigFetch = null; + }); + } - return result; + return _pendingConfigFetch; } // Router runtime config from environment diff --git a/tests/unit/router/config.test.ts b/tests/unit/router/config.test.ts index 55ebbf6f..3d0040b7 100644 --- a/tests/unit/router/config.test.ts +++ b/tests/unit/router/config.test.ts @@ -196,4 +196,34 @@ describe('loadProjectConfig', () => { await freshLoad(); expect(innerMock).toHaveBeenCalledTimes(2); }); + + it('deduplicates concurrent in-flight fetches (prevents cache stampede)', async () => { + let resolveDb!: (value: unknown) => void; + const dbPromise = new Promise((res) => { + resolveDb = res; + }); + const innerMock = vi.fn().mockReturnValue(dbPromise); + + vi.resetModules(); + vi.doMock('../../../src/config/provider.js', () => ({ loadConfig: innerMock })); + vi.doMock('../../../src/config/configCache.js', () => ({ + configCache: { getConfig: vi.fn().mockReturnValue(null), setConfig: vi.fn() }, + })); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + + // Fire two concurrent calls before the DB responds + const p1 = freshLoad(); + const p2 = freshLoad(); + + // Only one DB call should have been made + expect(innerMock).toHaveBeenCalledTimes(1); + + resolveDb({ projects: [] }); + const [r1, r2] = await Promise.all([p1, p2]); + + // Both resolve to the same object (deduplicated) + expect(r1).toBe(r2); + expect(innerMock).toHaveBeenCalledTimes(1); + }); });