fix(workspace-cache): memory leak in deleteFromLocalCache (#17686)

## Problem

The `deleteFromLocalCache` method was only setting `lastHashCheckedAt=0`
instead of actually deleting the cache entry. This caused old versions
to accumulate in the local cache when `invalidateAndRecompute` was
called.

### Flow that causes the leak:

1. `invalidateAndRecompute()` is called
2. `flush()` → `deleteFromLocalCache()` — **only sets
`lastHashCheckedAt=0`, keeps old data**
3. `recomputeDataFromProvider()` → `setInLocalCache()` — **adds new
version with new hash**
4. `cleanupStaleVersions()` — **never called** (only triggered from
`getFromLocalCache` path)

Result: Each `invalidateAndRecompute` call adds a new version to
`entry.versions` without removing the old one.

### Impact

For migration commands (like
`1-17-migrate-attachment-to-morph-relations`) that process thousands of
workspaces, this caused significant memory growth:
- Each workspace calls `getOrRecompute` (version 1)
- Then calls `invalidateAndRecompute` (version 2 added, version 1 stays)
- Memory accumulates as the command processes more workspaces

## Solution

Actually delete the local cache entry in `deleteFromLocalCache`, so
`recomputeDataFromProvider` starts fresh.

```typescript
// Before (memory leak)
if (isDefined(entry)) {
  entry.lastHashCheckedAt = 0;
}

// After (proper cleanup)
this.localCache.delete(localKey);
```

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-04 10:47:45 +01:00
committed by GitHub
parent 0edc3a385c
commit 414f68fb63
@@ -33,6 +33,8 @@ const LOCAL_ENTRY_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MEMOIZER_TTL_MS = 10_000; // 10 seconds
const STALE_VERSION_TTL_MS = 5_000; // 5 seconds
const MAX_LOCAL_STALE_VERSIONS = 5; // 5 stale versions
const MAX_LOCAL_CACHE_ENTRIES = 1_000;
const MIN_EVICT_KEYS = 100;
type CacheDataType = WorkspaceCacheDataMap[WorkspaceCacheKeyName];
@@ -391,6 +393,28 @@ export class WorkspaceCacheService implements OnModuleInit {
entry.versions.set(hash, { data, lastReadAt: Date.now() });
entry.latestHash = hash;
entry.lastHashCheckedAt = Date.now();
this.cleanupStaleVersions(entry);
this.evictLRUEntriesIfNeeded();
}
private evictLRUEntriesIfNeeded(): void {
if (this.localCache.size <= MAX_LOCAL_CACHE_ENTRIES) {
return;
}
const entries = [...this.localCache.entries()].sort(
(a, b) => a[1].lastHashCheckedAt - b[1].lastHashCheckedAt,
);
const toEvict = entries.slice(
0,
Math.max(MIN_EVICT_KEYS, this.localCache.size - MAX_LOCAL_CACHE_ENTRIES),
);
for (const [key] of toEvict) {
this.localCache.delete(key);
}
}
private cleanupStaleVersions(