From 77c5aa15e9a6d73da0db8fba85597267fcf110b6 Mon Sep 17 00:00:00 2001 From: Brava Tactical <149970717+BravaTactical@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:46:27 -0700 Subject: [PATCH] fix: stop domain cache re-caching from extending the TTL The verify-domain route calls cacheDomainByName on every request. In single-tenant mode resolveDomainFromHost returns the cached copy, so each call reset expiresAt and, under steady traffic, the entry never reloaded from MongoDB. Direct database changes stayed invisible until the app restarted. cacheDomainByName now keeps the expiry of an unexpired entry and only sets a fresh one when the entry is missing or expired. invalidateDomainCache is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/lib/__tests__/domain-cache.test.ts | 57 ++++++++++++++++++++- apps/web/lib/domain-cache.ts | 14 ++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/__tests__/domain-cache.test.ts b/apps/web/lib/__tests__/domain-cache.test.ts index 6a65cc2f2..5070b4d18 100644 --- a/apps/web/lib/__tests__/domain-cache.test.ts +++ b/apps/web/lib/__tests__/domain-cache.test.ts @@ -11,7 +11,12 @@ jest.mock("../../models/Domain", () => ({ })); import DomainModel from "../../models/Domain"; -import { getDomainFromHost } from "../domain-cache"; +import { + cacheDomainByName, + getCachedDomain, + getDomainFromHost, + invalidateDomainCache, +} from "../domain-cache"; describe("domain-cache host resolution", () => { const originalDomain = process.env.DOMAIN; @@ -53,3 +58,53 @@ describe("domain-cache host resolution", () => { expect(domain).toBe(school); }); }); + +describe("domain-cache expiry", () => { + const mockFindOne = (DomainModel as any).findOne as jest.Mock; + + beforeEach(() => { + mockFindOne.mockReset(); + jest.useFakeTimers(); + }); + + afterEach(() => { + invalidateDomainCache("main"); + jest.useRealTimers(); + }); + + it("reloads from the database after the TTL even when the entry is re-cached in between", async () => { + mockFindOne.mockResolvedValueOnce({ name: "main", version: 1 }); + + const first = await getCachedDomain("main"); + expect(first).toEqual({ name: "main", version: 1 }); + expect(mockFindOne).toHaveBeenCalledTimes(1); + + // A request 30 seconds later reads the cached copy and caches it again, + // the way the verify-domain route does. + jest.advanceTimersByTime(30_000); + const cached = await getCachedDomain("main"); + cacheDomainByName(cached!); + expect(mockFindOne).toHaveBeenCalledTimes(1); + + // 61 seconds after the first load the entry has expired, so the next + // read picks up a change made directly in the database. + jest.advanceTimersByTime(31_000); + mockFindOne.mockResolvedValueOnce({ name: "main", version: 2 }); + + const reloaded = await getCachedDomain("main"); + expect(mockFindOne).toHaveBeenCalledTimes(2); + expect(reloaded).toEqual({ name: "main", version: 2 }); + }); + + it("reloads from the database right after invalidation", async () => { + mockFindOne.mockResolvedValueOnce({ name: "main", version: 1 }); + await getCachedDomain("main"); + + invalidateDomainCache("main"); + mockFindOne.mockResolvedValueOnce({ name: "main", version: 2 }); + + const reloaded = await getCachedDomain("main"); + expect(mockFindOne).toHaveBeenCalledTimes(2); + expect(reloaded).toEqual({ name: "main", version: 2 }); + }); +}); diff --git a/apps/web/lib/domain-cache.ts b/apps/web/lib/domain-cache.ts index 65fede32d..f7e8fe500 100644 --- a/apps/web/lib/domain-cache.ts +++ b/apps/web/lib/domain-cache.ts @@ -75,9 +75,19 @@ export function cacheDomainByName(domain: Domain): void { return; } - domainCacheByName.set(normalizeDomainName(domain.name), { + const normalizedDomainName = normalizeDomainName(domain.name); + const existing = domainCacheByName.get(normalizedDomainName); + const now = Date.now(); + + domainCacheByName.set(normalizedDomainName, { data: (domain as any).toObject ? (domain as any).toObject() : domain, - expiresAt: Date.now() + TTL, + // Keep the expiry of an unexpired entry. Callers often re-cache the + // copy they just read from this cache, and resetting the expiry then + // would stop the entry from ever reloading from the database. + expiresAt: + existing && existing.expiresAt > now + ? existing.expiresAt + : now + TTL, }); }