Skip to content

Commit 8fde587

Browse files
khaliqgantclaude
andcommitted
Fix race conditions and add cache size limit for repos cache
- Add initializingConnections Set to prevent duplicate API calls when multiple requests hit simultaneously with empty cache - Add evictOldestCacheEntries() to cap cache at 500 entries (LRU eviction) - Call eviction after all cache writes to prevent unbounded memory growth 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent b952f6b commit 8fde587

File tree

1 file changed

+105
-62
lines changed

1 file changed

+105
-62
lines changed

src/cloud/api/workspaces.ts

Lines changed: 105 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ interface CachedUserRepos {
8181
const userReposCache = new Map<string, CachedUserRepos>();
8282
const USER_REPOS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes - hard expiry
8383
const STALE_WHILE_REVALIDATE_MS = 5 * 60 * 1000; // Trigger background refresh after 5 minutes
84+
const MAX_CACHE_ENTRIES = 500; // Prevent unbounded growth
85+
86+
/**
87+
* Evict oldest cache entries if we exceed the limit
88+
*/
89+
function evictOldestCacheEntries(): void {
90+
if (userReposCache.size <= MAX_CACHE_ENTRIES) return;
91+
92+
// Convert to array, sort by cachedAt (oldest first), delete oldest entries
93+
const entries = Array.from(userReposCache.entries())
94+
.sort((a, b) => a[1].cachedAt - b[1].cachedAt);
95+
96+
const toEvict = entries.slice(0, entries.length - MAX_CACHE_ENTRIES);
97+
for (const [key] of toEvict) {
98+
console.log(`[repos-cache] Evicting oldest cache entry: ${key.substring(0, 8)}`);
99+
userReposCache.delete(key);
100+
}
101+
}
84102

85103
/**
86104
* Background refresh function that paginates through ALL user repos
@@ -144,6 +162,7 @@ async function refreshUserReposInBackground(nangoConnectionId: string): Promise<
144162
isComplete: true,
145163
refreshInProgress: false,
146164
});
165+
evictOldestCacheEntries();
147166
} catch (err) {
148167
console.error(`[repos-cache] Background refresh failed for ${nangoConnectionId.substring(0, 8)}:`, err);
149168

@@ -182,81 +201,105 @@ function getCachedUserRepos(nangoConnectionId: string): CachedUserRepos | null {
182201
return cached;
183202
}
184203

204+
// Track in-flight initializations to prevent duplicate API calls
205+
const initializingConnections = new Set<string>();
206+
185207
/**
186208
* Initialize cache with first page and trigger background refresh for rest
187209
* Returns the first page of repos immediately
188210
*/
189211
async function initializeUserReposCache(nangoConnectionId: string): Promise<CachedRepo[]> {
190-
// Fetch first page synchronously
191-
const firstPage = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
192-
perPage: 100,
193-
page: 1,
194-
type: 'all',
195-
});
196-
197-
const repos = firstPage.repositories.map(r => ({
198-
fullName: r.fullName,
199-
permissions: r.permissions,
200-
}));
201-
202-
// Store first page immediately
203-
userReposCache.set(nangoConnectionId, {
204-
repositories: repos,
205-
cachedAt: Date.now(),
206-
isComplete: !firstPage.hasMore,
207-
refreshInProgress: firstPage.hasMore, // Will be refreshing if there's more
208-
});
209-
210-
// If there are more pages, trigger background refresh to get them all
211-
if (firstPage.hasMore) {
212-
console.log(`[repos-cache] First page has ${repos.length} repos, more available - triggering background refresh`);
213-
// Fire and forget - continue pagination in background
214-
(async () => {
215-
try {
216-
const allRepos = [...repos];
217-
let page = 2;
218-
let hasMore = true;
219-
const MAX_PAGES = 20;
220-
221-
while (hasMore && page <= MAX_PAGES) {
222-
const result = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
223-
perPage: 100,
224-
page,
225-
type: 'all',
226-
});
212+
// Check if another request is already initializing this connection
213+
if (initializingConnections.has(nangoConnectionId)) {
214+
console.log(`[repos-cache] Another request is initializing ${nangoConnectionId.substring(0, 8)}, waiting...`);
215+
// Wait a bit and check cache again
216+
await new Promise(resolve => setTimeout(resolve, 500));
217+
const cached = userReposCache.get(nangoConnectionId);
218+
if (cached) {
219+
return cached.repositories;
220+
}
221+
// Still no cache, fall through to initialize (previous request may have failed)
222+
}
227223

228-
allRepos.push(...result.repositories.map(r => ({
229-
fullName: r.fullName,
230-
permissions: r.permissions,
231-
})));
224+
initializingConnections.add(nangoConnectionId);
225+
226+
try {
227+
// Fetch first page synchronously
228+
const firstPage = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
229+
perPage: 100,
230+
page: 1,
231+
type: 'all',
232+
});
232233

233-
hasMore = result.hasMore;
234-
page++;
234+
const repos = firstPage.repositories.map(r => ({
235+
fullName: r.fullName,
236+
permissions: r.permissions,
237+
}));
235238

236-
if (hasMore) {
237-
await new Promise(resolve => setTimeout(resolve, 100));
239+
// Store first page immediately
240+
userReposCache.set(nangoConnectionId, {
241+
repositories: repos,
242+
cachedAt: Date.now(),
243+
isComplete: !firstPage.hasMore,
244+
refreshInProgress: firstPage.hasMore, // Will be refreshing if there's more
245+
});
246+
evictOldestCacheEntries();
247+
248+
// If there are more pages, trigger background refresh to get the rest
249+
if (firstPage.hasMore) {
250+
console.log(`[repos-cache] First page has ${repos.length} repos, more available - triggering background pagination`);
251+
// Fire and forget - reuse the shared background refresh function
252+
// But start from page 2 with the existing repos
253+
(async () => {
254+
try {
255+
const allRepos = [...repos];
256+
let page = 2;
257+
let hasMore = true;
258+
const MAX_PAGES = 20;
259+
260+
while (hasMore && page <= MAX_PAGES) {
261+
const result = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
262+
perPage: 100,
263+
page,
264+
type: 'all',
265+
});
266+
267+
allRepos.push(...result.repositories.map(r => ({
268+
fullName: r.fullName,
269+
permissions: r.permissions,
270+
})));
271+
272+
hasMore = result.hasMore;
273+
page++;
274+
275+
if (hasMore) {
276+
await new Promise(resolve => setTimeout(resolve, 100));
277+
}
238278
}
239-
}
240279

241-
console.log(`[repos-cache] Background pagination complete: ${allRepos.length} total repos`);
280+
console.log(`[repos-cache] Background pagination complete: ${allRepos.length} total repos`);
242281

243-
userReposCache.set(nangoConnectionId, {
244-
repositories: allRepos,
245-
cachedAt: Date.now(),
246-
isComplete: true,
247-
refreshInProgress: false,
248-
});
249-
} catch (err) {
250-
console.error('[repos-cache] Background pagination failed:', err);
251-
const existing = userReposCache.get(nangoConnectionId);
252-
if (existing) {
253-
existing.refreshInProgress = false;
282+
userReposCache.set(nangoConnectionId, {
283+
repositories: allRepos,
284+
cachedAt: Date.now(),
285+
isComplete: true,
286+
refreshInProgress: false,
287+
});
288+
evictOldestCacheEntries();
289+
} catch (err) {
290+
console.error('[repos-cache] Background pagination failed:', err);
291+
const existing = userReposCache.get(nangoConnectionId);
292+
if (existing) {
293+
existing.refreshInProgress = false;
294+
}
254295
}
255-
}
256-
})();
257-
}
296+
})();
297+
}
258298

259-
return repos;
299+
return repos;
300+
} finally {
301+
initializingConnections.delete(nangoConnectionId);
302+
}
260303
}
261304

262305
// ============================================================================

0 commit comments

Comments
 (0)