fix(login): replace custom SWR cache with lru-cache (#11945)

# Which Problems Are Solved

The in-memory SWR promiseCache in zitadel.ts used keys like
getBrandingSettings-${org} that had no instance identifier. In
multi-tenant mode, where a single Next.js process serves multiple
instances, an instance A's cached settings without org context
(branding, languages, settings, etc.) could be served to Instance B
request without org context. The cache now additionally is bound to 100
entries by default (configurable via maxSize in `API_CACHE_CONFIG`).
When capacity is exceeded, expired entries are swept first

It additionally replaces the hand-rolled PromiseCache (Map-based, FIFO
eviction) with lru-cache, leveraging its built-in fetchMethod for
stale-while-revalidate, request deduplication, and true LRU eviction.

# How the Problems Are Solved

Added an instanceCacheKey() helpe that prefixes every cache key with
serviceConfig.instanceHost in addition to the org context if available.

---------

Co-authored-by: Ramon <mail@conblem.me>
This commit is contained in:
Max Peintner
2026-04-13 11:19:50 +02:00
committed by GitHub
co-authored by Ramon
parent 551883dcab
commit 7d231b43d4
6 changed files with 363 additions and 26 deletions
+4 -3
View File
@@ -85,12 +85,13 @@ declare namespace NodeJS {
API_CACHE_ENABLED?: string;
/**
* Optional: JSON string to configure the cache TTLs (in minutes) for specific backend API routes or global fallbacks.
* Example: '{"defaultMinutes": 15, "longMinutes": 60, "getBrandingSettings": 120}'
*
* Optional: JSON string to configure the cache TTLs (in minutes) and size limits for specific backend API routes or global fallbacks.
* Example: '{"defaultMinutes": 15, "longMinutes": 60, "maxSize": 200, "getBrandingSettings": 120}'
*
* Properties:
* - \`defaultMinutes\`: The globally utilized default TTL in minutes (falls back to 15 if not set).
* - \`longMinutes\`: The TTL utilized string for long-cached routes, like branding/translation (falls back to 60 if not set).
* - \`maxSize\`: Maximum number of entries the in-memory cache may hold. Oldest entries are evicted when capacity is reached (defaults to 100).
* - \`[route_name]\`: Explicit overrides per specific API method (e.g., \`getHostedLoginTranslation\`).
*/
API_CACHE_CONFIG?: string;
+1
View File
@@ -41,6 +41,7 @@
"copy-to-clipboard": "^3.3.3",
"deepmerge": "^4.3.1",
"escape-html": "^1.0.3",
"lru-cache": "^11.3.2",
"lucide-react": "^0.577.0",
"moment": "^2.30.1",
"next": "16.2.2",
+251
View File
@@ -0,0 +1,251 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { PromiseCache } from "./cache";
// Suppress logger output during tests
vi.mock("./logger", () => ({
createLogger: () => ({
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
/**
* Creates a mock clock for lru-cache's `perf` option.
* lru-cache uses `performance.now()` internally for TTL tracking, and caches
* the reference at import time. The `perf` option lets us inject a custom clock.
*/
function mockClock(initial = 1000) {
let now = initial;
return {
perf: { now: () => now },
advance: (ms: number) => {
now += ms;
},
};
}
describe("PromiseCache", () => {
let cache: PromiseCache;
afterEach(() => {
cache?.clear();
});
describe("getOrFetch", () => {
test("should return the fetcher result on cache miss", async () => {
cache = new PromiseCache(10);
const result = await cache.getOrFetch("key1", () => Promise.resolve("value1"), 60_000);
expect(result).toBe("value1");
expect(cache.size).toBe(1);
});
test("should return cached value on cache hit", async () => {
cache = new PromiseCache(10);
let callCount = 0;
const fetcher = () => {
callCount++;
return Promise.resolve(`value-${callCount}`);
};
const first = await cache.getOrFetch("key1", fetcher, 60_000);
const second = await cache.getOrFetch("key1", fetcher, 60_000);
expect(first).toBe("value-1");
expect(second).toBe("value-1");
expect(callCount).toBe(1);
});
test("should return stale value immediately after TTL expires (SWR)", async () => {
const clock = mockClock();
cache = new PromiseCache(10, clock.perf);
let callCount = 0;
let resolveRevalidation: ((v: string) => void) | undefined;
const fetcher = () => {
callCount++;
if (callCount === 1) {
return Promise.resolve("value-1");
}
// Second call: return a promise we control
return new Promise<string>((resolve) => {
resolveRevalidation = resolve;
});
};
// First call — blocks on fetch
const first = await cache.getOrFetch("key1", fetcher, 100);
expect(first).toBe("value-1");
expect(callCount).toBe(1);
// Expire the entry
clock.advance(101);
// Second call after expiry — should get stale value immediately
const second = await cache.getOrFetch("key1", fetcher, 100);
expect(second).toBe("value-1"); // stale value, not blocking
expect(callCount).toBe(2); // revalidation was triggered
// Third call while revalidation is in-flight — also gets stale value
const third = await cache.getOrFetch("key1", fetcher, 100);
expect(third).toBe("value-1"); // stale value
expect(callCount).toBe(2); // no additional fetch (already revalidating)
// Resolve the background revalidation and flush the .then() chain
resolveRevalidation!("value-2");
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
// Now we should get the fresh value
const fourth = await cache.getOrFetch("key1", fetcher, 100);
expect(fourth).toBe("value-2");
});
test("should keep stale value when revalidation fails", async () => {
const clock = mockClock();
cache = new PromiseCache(10, clock.perf);
let callCount = 0;
const fetcher = () => {
callCount++;
if (callCount === 1) {
return Promise.resolve("value-1");
}
return Promise.reject(new Error("network error"));
};
const first = await cache.getOrFetch("key1", fetcher, 100);
expect(first).toBe("value-1");
clock.advance(101);
// After TTL: returns stale, triggers failing revalidation
const second = await cache.getOrFetch("key1", fetcher, 100);
expect(second).toBe("value-1");
expect(callCount).toBe(2);
// Flush the rejected promise's handlers
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
// Next call can retry
clock.advance(1);
const third = await cache.getOrFetch("key1", fetcher, 100);
expect(third).toBe("value-1"); // still stale
expect(callCount).toBe(3); // retried
});
test("should re-fetch after entry is evicted", async () => {
cache = new PromiseCache(10);
let callCount = 0;
const fetcher = () => {
callCount++;
return Promise.resolve(`value-${callCount}`);
};
const first = await cache.getOrFetch("key1", fetcher, 60_000);
expect(first).toBe("value-1");
// Evict the entry manually
cache.clear();
const second = await cache.getOrFetch("key1", fetcher, 60_000);
expect(second).toBe("value-2");
expect(callCount).toBe(2);
});
test("should reject on first-fetch failure", async () => {
cache = new PromiseCache(10);
const failingFetcher = () => Promise.reject(new Error("fail"));
await expect(cache.getOrFetch("key1", failingFetcher, 60_000)).rejects.toThrow();
// Wait a tick for internal cleanup
await new Promise((r) => setTimeout(r, 0));
expect(cache.size).toBe(0);
});
test("should deduplicate concurrent requests for the same key", async () => {
cache = new PromiseCache(10);
let callCount = 0;
const fetcher = () => {
callCount++;
return new Promise<string>((resolve) => setTimeout(() => resolve(`value-${callCount}`), 10));
};
const [a, b] = await Promise.all([
cache.getOrFetch("key1", fetcher, 60_000),
cache.getOrFetch("key1", fetcher, 60_000),
]);
expect(a).toBe("value-1");
expect(b).toBe("value-1");
expect(callCount).toBe(1);
});
});
describe("maxSize eviction", () => {
test("should evict entries when maxSize is exceeded", async () => {
cache = new PromiseCache(3);
await cache.getOrFetch("a", () => Promise.resolve(1), 60_000);
await cache.getOrFetch("b", () => Promise.resolve(2), 60_000);
await cache.getOrFetch("c", () => Promise.resolve(3), 60_000);
expect(cache.size).toBe(3);
// Adding a 4th entry should trigger eviction of the LRU entry ("a")
await cache.getOrFetch("d", () => Promise.resolve(4), 60_000);
expect(cache.size).toBe(3);
// "a" should have been evicted — re-fetching should call a new fetcher
let refetched = false;
await cache.getOrFetch(
"a",
() => {
refetched = true;
return Promise.resolve(10);
},
60_000,
);
expect(refetched).toBe(true);
});
test("should respect maxSize of 1", async () => {
cache = new PromiseCache(1);
await cache.getOrFetch("a", () => Promise.resolve(1), 60_000);
expect(cache.size).toBe(1);
await cache.getOrFetch("b", () => Promise.resolve(2), 60_000);
expect(cache.size).toBe(1);
// Only "b" should remain
let aRefetched = false;
await cache.getOrFetch(
"a",
() => {
aRefetched = true;
return Promise.resolve(10);
},
60_000,
);
expect(aRefetched).toBe(true);
});
});
describe("clear", () => {
test("should remove all entries", async () => {
cache = new PromiseCache(10);
await cache.getOrFetch("a", () => Promise.resolve(1), 60_000);
await cache.getOrFetch("b", () => Promise.resolve(2), 60_000);
expect(cache.size).toBe(2);
cache.clear();
expect(cache.size).toBe(0);
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import { LRUCache } from "lru-cache";
interface FetchContext {
fetcher: () => Promise<any>;
}
/**
* A bounded, stale-while-revalidate in-memory promise cache backed by lru-cache.
*
* Features:
* - True LRU eviction
* - Deduplicates concurrent requests (built-in to lru-cache's fetchMethod)
* - Serves stale data immediately while revalidating in the background
* - Keeps stale value on fetch rejection
* - Bounded to `maxSize` entries to prevent unbounded memory growth
*/
export class PromiseCache {
private readonly cache: LRUCache<string, any, FetchContext>;
constructor(maxSize = 100_000, perf?: { now: () => number }) {
this.cache = new LRUCache<string, any, FetchContext>({
max: Math.max(1, maxSize),
// A global TTL is required to initialize lru-cache's TTL tracking internals.
// Per-entry TTLs passed to fetch() will override this default.
ttl: 1,
allowStale: true,
noDeleteOnStaleGet: true,
noDeleteOnFetchRejection: true,
allowStaleOnFetchRejection: true,
fetchMethod: async (_key, _staleValue, { context }) => {
return context.fetcher();
},
...(perf ? { perf, ttlResolution: 0 } : {}),
});
}
/**
* Get a cached value or execute the fetcher and cache its result.
*
* After the first successful fetch, expired entries return the stale
* value immediately and trigger a background revalidation (SWR).
* Only the very first call for a key (or after eviction) blocks
* on the fetch.
*/
getOrFetch<T>(key: string, fetcher: () => Promise<T>, ttlMs: number): Promise<T> {
return this.cache.forceFetch(key, {
ttl: ttlMs,
context: { fetcher },
}) as Promise<T>;
}
/** Current number of entries (including stale). */
get size(): number {
return this.cache.size;
}
/** Clear all entries. */
clear(): void {
this.cache.clear();
}
}
+37 -23
View File
@@ -37,6 +37,7 @@ import { createLogger } from "./logger";
import { errorClassificationInterceptor } from "@/lib/grpc/interceptors/error-classification";
import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel";
import { Code, ConnectError, Interceptor } from "@connectrpc/connect";
import { PromiseCache } from "./cache";
import { createServiceForHost } from "./service";
const logger = createLogger("zitadel");
@@ -67,29 +68,30 @@ function getTTLForKey(keyPrefix: string, fallbackTtl: number) {
return fallbackTtl;
}
const promiseCache = new Map<string, { promise: Promise<any>; expiresAt: number }>();
/**
* Build a cache key scoped to the current instance.
* In multi-tenant mode serviceConfig.instanceHost distinguishes tenants;
* without it we fall back to "default" (single-tenant / self-hosted).
*/
function instanceCacheKey(serviceConfig: ServiceConfig, key: string): string {
return `${serviceConfig.instanceHost || "default"}:${key}`;
}
const promiseCache = new PromiseCache(Number(cacheConfig.maxSize) || 100);
/**
* A stale-while-revalidate in-memory cache to keep data fresh and deduplicate concurrent requests.
* We cache the Promise, so concurrent requests share the exact same execution.
*
* The cache is bounded and periodically swept for expired entries
* to prevent unbounded memory growth.
*/
function freshCache<T>(key: string, fetcher: () => Promise<T>, ttlMs: number): Promise<T> {
if (!useCache) {
return fetcher();
}
const now = Date.now();
const cached = promiseCache.get(key);
if (cached && now < cached.expiresAt) {
return cached.promise;
}
const promise = fetcher();
promiseCache.set(key, { promise, expiresAt: now + ttlMs });
promise.catch(() => promiseCache.delete(key));
return promise;
return promiseCache.getOrFetch(key, fetcher, ttlMs);
}
export async function getHostedLoginTranslation({
@@ -125,7 +127,7 @@ export async function getHostedLoginTranslation({
};
return freshCache(
`getHostedLoginTranslation-${organization || "instance"}-${locale || "default"}`,
instanceCacheKey(serviceConfig, `getHostedLoginTranslation-${organization || "instance"}-${locale || "default"}`),
fetcher,
getTTLForKey("getHostedLoginTranslation", longCacheTTL),
);
@@ -146,7 +148,7 @@ export async function getBrandingSettings({
};
return freshCache(
`getBrandingSettings-${organization || "instance"}`,
instanceCacheKey(serviceConfig, `getBrandingSettings-${organization || "instance"}`),
fetcher,
getTTLForKey("getBrandingSettings", longCacheTTL),
);
@@ -167,7 +169,7 @@ export async function getLoginSettings({
};
return freshCache(
`getLoginSettings-${organization || "instance"}`,
instanceCacheKey(serviceConfig, `getLoginSettings-${organization || "instance"}`),
fetcher,
getTTLForKey("getLoginSettings", defaultCacheTTL),
);
@@ -180,7 +182,11 @@ export async function getSecuritySettings({ serviceConfig }: WithServiceConfig)
return settingsService.getSecuritySettings({}).then((resp) => (resp.settings ? resp.settings : undefined));
};
return freshCache(`getSecuritySettings-instance`, fetcher, getTTLForKey("getSecuritySettings", defaultCacheTTL));
return freshCache(
instanceCacheKey(serviceConfig, `getSecuritySettings-instance`),
fetcher,
getTTLForKey("getSecuritySettings", defaultCacheTTL),
);
}
export async function getLockoutSettings({ serviceConfig, orgId }: WithServiceConfig<{ orgId?: string }>) {
@@ -193,7 +199,7 @@ export async function getLockoutSettings({ serviceConfig, orgId }: WithServiceCo
};
return freshCache(
`getLockoutSettings-${orgId || "instance"}`,
instanceCacheKey(serviceConfig, `getLockoutSettings-${orgId || "instance"}`),
fetcher,
getTTLForKey("getLockoutSettings", defaultCacheTTL),
);
@@ -209,7 +215,7 @@ export async function getPasswordExpirySettings({ serviceConfig, orgId }: WithSe
};
return freshCache(
`getPasswordExpirySettings-${orgId || "instance"}`,
instanceCacheKey(serviceConfig, `getPasswordExpirySettings-${orgId || "instance"}`),
fetcher,
getTTLForKey("getPasswordExpirySettings", defaultCacheTTL),
);
@@ -251,7 +257,11 @@ export async function getAllowedLanguages({ serviceConfig }: WithServiceConfig)
});
};
return freshCache(`getGeneralSettings-instance`, fetcher, getTTLForKey("getGeneralSettings", longCacheTTL));
return freshCache(
instanceCacheKey(serviceConfig, `getGeneralSettings-instance`),
fetcher,
getTTLForKey("getGeneralSettings", longCacheTTL),
);
}
export async function getLegalAndSupportSettings({
@@ -269,7 +279,7 @@ export async function getLegalAndSupportSettings({
};
return freshCache(
`getLegalAndSupportSettings-${organization || "instance"}`,
instanceCacheKey(serviceConfig, `getLegalAndSupportSettings-${organization || "instance"}`),
fetcher,
getTTLForKey("getLegalAndSupportSettings", longCacheTTL),
);
@@ -290,7 +300,7 @@ export async function getPasswordComplexitySettings({
};
return freshCache(
`getPasswordComplexitySettings-${organization || "instance"}`,
instanceCacheKey(serviceConfig, `getPasswordComplexitySettings-${organization || "instance"}`),
fetcher,
getTTLForKey("getPasswordComplexitySettings", defaultCacheTTL),
);
@@ -850,7 +860,11 @@ export async function getDefaultOrg({ serviceConfig }: WithServiceConfig): Promi
};
return useCache
? freshCache(`getDefaultOrg-${"instance"}`, fetcher, getTTLForKey("getDefaultOrg", defaultCacheTTL))
? freshCache(
instanceCacheKey(serviceConfig, "getDefaultOrg-instance"),
fetcher,
getTTLForKey("getDefaultOrg", defaultCacheTTL),
)
: fetcher();
}
+9
View File
@@ -256,6 +256,9 @@ importers:
escape-html:
specifier: ^1.0.3
version: 1.0.3
lru-cache:
specifier: ^11.3.2
version: 11.3.2
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
@@ -9286,6 +9289,10 @@ packages:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
lru-cache@11.3.2:
resolution: {integrity: sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -21867,6 +21874,8 @@ snapshots:
lru-cache@11.2.7: {}
lru-cache@11.3.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1