Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 39 additions & 28 deletions src/router/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/router/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading