diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index d3757ce46f..f56784cbc6 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -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; diff --git a/apps/login/package.json b/apps/login/package.json index 95de4f9f1c..4bdfcc83f6 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -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", diff --git a/apps/login/src/lib/cache.test.ts b/apps/login/src/lib/cache.test.ts new file mode 100644 index 0000000000..90b90b75a5 --- /dev/null +++ b/apps/login/src/lib/cache.test.ts @@ -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((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((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); + }); + }); +}); diff --git a/apps/login/src/lib/cache.ts b/apps/login/src/lib/cache.ts new file mode 100644 index 0000000000..991e00a01e --- /dev/null +++ b/apps/login/src/lib/cache.ts @@ -0,0 +1,61 @@ +import { LRUCache } from "lru-cache"; + +interface FetchContext { + fetcher: () => Promise; +} + +/** + * 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; + + constructor(maxSize = 100_000, perf?: { now: () => number }) { + this.cache = new LRUCache({ + 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(key: string, fetcher: () => Promise, ttlMs: number): Promise { + return this.cache.forceFetch(key, { + ttl: ttlMs, + context: { fetcher }, + }) as Promise; + } + + /** Current number of entries (including stale). */ + get size(): number { + return this.cache.size; + } + + /** Clear all entries. */ + clear(): void { + this.cache.clear(); + } +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index d8b606c5ef..ec0cb05a79 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -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; 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(key: string, fetcher: () => Promise, ttlMs: number): Promise { 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(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9c99bc27e..f59c903fb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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