What happened?
standardCache() in packages/api/src/cache/cacheFactory.ts only memoizes its plain in-memory path (inMemoryCacheMap) — the Redis-backed path builds a brand-new KeyvRedis + Keyv pair on every call, with no disposal:
export const standardCache = (namespace: string, ttl?: number, fallbackStore?: object): Keyv => {
if (keyvRedisClient && !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)) {
const keyvRedis = new KeyvRedis(keyvRedisClient); // <-- fresh every call, no memoization
const cache = new Keyv(keyvRedis, { namespace, ttl });
...
return instrumentRedisCache(cache, namespace);
}
...
};
@keyv/redis@5.1.6's KeyvRedis constructor calls initClient(), which registers 4 listeners (error, connect, disconnect, reconnecting) directly onto whatever client it's given — and here that's the shared, process-wide keyvRedisClient from redisClients.ts, not a private client. There is no removeListener/off/removeAllListeners anywhere in @keyv/redis, and its disconnect() only closes the socket — it never detaches listeners. Each listener is a closure capturing this ((error) => { this.emit('error', error); }), so every abandoned KeyvRedis+Keyv pair is kept alive forever purely by its own listener registration on the shared client.
At least one real call site hits this on every invocation with no memoization of its own: packages/api/src/endpoints/models.ts, fetchModels():
const modelsCache = shouldCache ? standardCache(CacheKeys.MODEL_QUERIES) : null;
shouldCache is true in the common case. fetchModels() is called from packages/api/src/endpoints/config/models.ts once per configured custom/Azure endpoint whenever the app resolves a model list (page load, endpoint switch, conversation setup).
Expected: repeated standardCache(namespace) calls for the same namespace, Redis enabled or not, return the same memoized instance — matching the design intent already implemented for the in-memory path (see #12673), whose reasoning ("Redis-backed instances are unaffected since they already share a backend") covers data-visibility but not object lifecycle.
I traced this while investigating a production OOM (container hitting its 2Gi memory limit after ~48h uptime). Two V8 heap snapshots taken ~6h42m apart, diffed by parsing both .heapsnapshot files and aggregating self-size/count per object type+name, showed four object types growing in exact lockstep:
| Type |
Growth over 6h42m |
Keyv instances |
+138 |
KeyvRedis instances |
+138 |
HooksManager instances (Keyv's own internal instrumentation) |
+138 |
StatsManager instances (ditto) |
+138 |
~1 new, never-released pair every 2.9 minutes. Independently, packages/api/src/cache/redisClients.ts calls keyvRedisClient.setMaxListeners(cacheConfig.REDIS_MAX_LISTENERS) to raise Node's default listener-count warning threshold — which this file's own code (5 listeners, registered once at module load) would never need on its own. That override only makes sense as a workaround for exactly this accumulation, and matches the long-running MaxListenersExceededWarning: ... added to [KeyvRedis] reports in #6170 (still open as of December 2025).
Suggested fix: extend the existing inMemoryCacheMap memoization pattern to the Redis-backed branch too, keyed by namespace, so repeated calls return the same instance instead of constructing (and abandoning) a new one every time.
Version Information
Reproduced by source inspection against the official v0.8.8-rc3 tag (commit 1df448481075d37484f11c4751dc3f60314ee651), confirmed byte-identical on the affected files (cacheFactory.ts, redisClients.ts, endpoints/models.ts, endpoints/config/models.ts) to our own deployment's fork branch — not something introduced downstream. @keyv/redis 5.1.6 (per package-lock.json). USE_REDIS=true, standalone (non-cluster) Redis. Original OOM observed in a Docker/Kubernetes deployment, Node.js container with a 2Gi memory limit.
Steps to Reproduce
- Deploy with
USE_REDIS=true (standalone Redis).
- Minimal repro (no heap snapshot needed): call
standardCache('some-namespace') twice in a row and compare the two return values — they are not the same object reference (=== false), unlike the non-Redis path, which does return the same instance on a second call.
- Full production repro: call
fetchModels() repeatedly (e.g. load the app / switch endpoints / open new conversations against a custom or Azure endpoint several times over a few hours).
- Capture a heap snapshot (
node --heapsnapshot-signal=SIGUSR2, then kill -SIGUSR2 <pid>), wait, capture a second one, and diff object counts by type+name — Keyv, KeyvRedis, HooksManager, and StatsManager instance counts grow in lockstep with no upper bound.
What browsers are you seeing the problem on?
No response
Relevant log output
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 21 error listeners added to [KeyvRedis]. Use emitter.setMaxListeners() to increase limit
(representative of the class of warning this produces once enough instances accumulate on the shared client — same signature reported in #6170)
Screenshots
No response
Code of Conduct
What happened?
standardCache()inpackages/api/src/cache/cacheFactory.tsonly memoizes its plain in-memory path (inMemoryCacheMap) — the Redis-backed path builds a brand-newKeyvRedis+Keyvpair on every call, with no disposal:@keyv/redis@5.1.6'sKeyvRedisconstructor callsinitClient(), which registers 4 listeners (error,connect,disconnect,reconnecting) directly onto whatever client it's given — and here that's the shared, process-widekeyvRedisClientfromredisClients.ts, not a private client. There is noremoveListener/off/removeAllListenersanywhere in@keyv/redis, and itsdisconnect()only closes the socket — it never detaches listeners. Each listener is a closure capturingthis((error) => { this.emit('error', error); }), so every abandonedKeyvRedis+Keyvpair is kept alive forever purely by its own listener registration on the shared client.At least one real call site hits this on every invocation with no memoization of its own:
packages/api/src/endpoints/models.ts,fetchModels():shouldCacheis true in the common case.fetchModels()is called frompackages/api/src/endpoints/config/models.tsonce per configured custom/Azure endpoint whenever the app resolves a model list (page load, endpoint switch, conversation setup).Expected: repeated
standardCache(namespace)calls for the same namespace, Redis enabled or not, return the same memoized instance — matching the design intent already implemented for the in-memory path (see #12673), whose reasoning ("Redis-backed instances are unaffected since they already share a backend") covers data-visibility but not object lifecycle.I traced this while investigating a production OOM (container hitting its 2Gi memory limit after ~48h uptime). Two V8 heap snapshots taken ~6h42m apart, diffed by parsing both
.heapsnapshotfiles and aggregating self-size/count per object type+name, showed four object types growing in exact lockstep:KeyvinstancesKeyvRedisinstancesHooksManagerinstances (Keyv's own internal instrumentation)StatsManagerinstances (ditto)~1 new, never-released pair every 2.9 minutes. Independently,
packages/api/src/cache/redisClients.tscallskeyvRedisClient.setMaxListeners(cacheConfig.REDIS_MAX_LISTENERS)to raise Node's default listener-count warning threshold — which this file's own code (5 listeners, registered once at module load) would never need on its own. That override only makes sense as a workaround for exactly this accumulation, and matches the long-runningMaxListenersExceededWarning: ... added to [KeyvRedis]reports in #6170 (still open as of December 2025).Suggested fix: extend the existing
inMemoryCacheMapmemoization pattern to the Redis-backed branch too, keyed by namespace, so repeated calls return the same instance instead of constructing (and abandoning) a new one every time.Version Information
Reproduced by source inspection against the official
v0.8.8-rc3tag (commit1df448481075d37484f11c4751dc3f60314ee651), confirmed byte-identical on the affected files (cacheFactory.ts,redisClients.ts,endpoints/models.ts,endpoints/config/models.ts) to our own deployment's fork branch — not something introduced downstream.@keyv/redis5.1.6(perpackage-lock.json).USE_REDIS=true, standalone (non-cluster) Redis. Original OOM observed in a Docker/Kubernetes deployment, Node.js container with a 2Gi memory limit.Steps to Reproduce
USE_REDIS=true(standalone Redis).standardCache('some-namespace')twice in a row and compare the two return values — they are not the same object reference (===false), unlike the non-Redis path, which does return the same instance on a second call.fetchModels()repeatedly (e.g. load the app / switch endpoints / open new conversations against a custom or Azure endpoint several times over a few hours).node --heapsnapshot-signal=SIGUSR2, thenkill -SIGUSR2 <pid>), wait, capture a second one, and diff object counts by type+name —Keyv,KeyvRedis,HooksManager, andStatsManagerinstance counts grow in lockstep with no upper bound.What browsers are you seeing the problem on?
No response
Relevant log output
Screenshots
No response
Code of Conduct