fix(cache): stop serving stale site-settings defaults after deploy
CI / check (push) Successful in 1m3s
CI / deploy (push) Successful in 57s

The site-settings loader kept an in-process map forever after a Redis miss
and promoted DEFAULTS (no logo/theme) to Redis on any DB error, so a build
that started before the DB was reachable stuck the site on the preset logo
and default theme until a manual reload or full restart.

- Redis miss now reloads from the database instead of the stale in-process map
- a DB failure returns defaults only as an in-process last resort and never
  writes them to Redis, so the shared cache can't be poisoned by a transient
  error at startup
- regression tests: DB re-read on Redis miss after cache expiry, defaults never
  promoted to Redis, recovery from transient DB failure
This commit is contained in:
openhands committed 2026-09-06 12:56:29 +02:00
1 parent 9dc9d1fa4b
commit 35f66d879f
2 files changed
+69 -7

No files matched your search

+46 -1
View File
@@ -1,6 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { selectFrom } = vi.hoisted(() => ({ selectFrom: vi.fn() })); const { selectFrom, redisMock } = vi.hoisted(() => ({
selectFrom: vi.fn(),
redisMock: {
get: vi.fn(),
setex: vi.fn(),
del: vi.fn(),
},
}));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", () => ({
db: { db: {
select: () => ({ select: () => ({
@@ -15,10 +23,18 @@ vi.mock("@/lib/db", () => ({
WebsiteSetting: { key: "WebsiteSetting.key", value: "WebsiteSetting.value" }, WebsiteSetting: { key: "WebsiteSetting.key", value: "WebsiteSetting.value" },
})); }));
vi.mock("@/lib/redis", () => ({ redis: redisMock }));
import { siteSettings } from "./site-settings"; import { siteSettings } from "./site-settings";
beforeEach(async () => { beforeEach(async () => {
selectFrom.mockReset(); selectFrom.mockReset();
redisMock.get.mockReset();
redisMock.setex.mockReset();
redisMock.del.mockReset();
redisMock.get.mockResolvedValue(null);
redisMock.setex.mockResolvedValue(undefined);
redisMock.del.mockResolvedValue(undefined);
await siteSettings.reload(); await siteSettings.reload();
}); });
@@ -53,4 +69,33 @@ describe("siteSettings", () => {
await siteSettings.get("hotel_name"); await siteSettings.get("hotel_name");
expect(selectFrom).toHaveBeenCalledTimes(2); expect(selectFrom).toHaveBeenCalledTimes(2);
}); });
it("re-reads the database on a Redis miss instead of serving stale cache", async () => {
vi.useFakeTimers();
try {
selectFrom.mockResolvedValue([{ key: "hotel_name", value: "First" }]);
expect(await siteSettings.get("hotel_name")).toBe("First");
selectFrom.mockResolvedValue([{ key: "hotel_name", value: "Second" }]);
vi.setSystemTime(Date.now() + 2 * 60_000); // expire the in-memory cache
expect(await siteSettings.get("hotel_name")).toBe("Second");
} finally {
vi.useRealTimers();
}
});
it("does not promote defaults to Redis when the database fails", async () => {
selectFrom.mockRejectedValue(new Error("db down"));
expect(await siteSettings.get("hotel_name", "fallback")).toBe("fallback");
expect(redisMock.setex).not.toHaveBeenCalled();
});
it("recovers from a transient database failure on the next load", async () => {
selectFrom.mockRejectedValueOnce(new Error("db down"));
expect(await siteSettings.get("hotel_name", "fallback")).toBe("fallback");
selectFrom.mockResolvedValueOnce([{ key: "hotel_name", value: "Live" }]);
expect(await siteSettings.get("hotel_name")).toBe("Live");
expect(redisMock.setex).toHaveBeenCalledTimes(1);
});
}); });
+23 -6
View File
@@ -32,15 +32,15 @@ class SiteSettings {
// not each hammer Redis/DB (cache-stampede protection). // not each hammer Redis/DB (cache-stampede protection).
private inFlight: Promise<Map<string, string>> | null = null; private inFlight: Promise<Map<string, string>> | null = null;
private async loadFromDb(): Promise<Map<string, string>> { private async loadFromDb(): Promise<Map<string, string> | null> {
try { try {
const rows = await db const rows = await db
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value }) .select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting); .from(WebsiteSetting);
return new Map(rows.map((r) => [r.key, r.value])); return new Map(rows.map((r) => [r.key, r.value]));
} catch { } catch {
logger.warn("Failed to load site settings from database, using defaults"); logger.warn("Failed to load site settings from database");
return new Map(Object.entries(DEFAULTS)); return null;
} }
} }
@@ -59,10 +59,11 @@ class SiteSettings {
} }
} }
// Expired but usable fallback — keeps the site up if both Redis and DB fail. // Redis miss/unavailable → the database is authoritative. Reloading here
if (this.cache !== null) return this.cache.map; // (instead of serving the in-process map) is what picks up changes made
// by other instances after the Redis TTL expires.
const map = await this.loadFromDb(); const map = await this.loadFromDb();
if (map) {
this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS }; this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS };
if (redis) { if (redis) {
@@ -81,6 +82,22 @@ class SiteSettings {
return map; return map;
} }
// DB unavailable → keep serving the last known-good map from memory.
if (this.cache !== null) {
logger.warn("Site settings: database unreachable, serving stale cache");
return this.cache.map;
}
// Nothing cached in this process and the DB is down → last resort. These
// defaults are kept in-memory only and never written to Redis, so a
// momentary DB failure at startup can't poison the shared cache and
// downgrade every instance to the default/preset branding.
logger.warn(
"Site settings: database unreachable and no cached copy, using defaults",
);
return new Map(Object.entries(DEFAULTS));
}
private async load(): Promise<Map<string, string>> { private async load(): Promise<Map<string, string>> {
if (this.cache !== null) { if (this.cache !== null) {
if (IS_PRERENDER || this.cache.expiresAt > Date.now()) { if (IS_PRERENDER || this.cache.expiresAt > Date.now()) {