cache DB settings, used cached db settings in ProxyResolver, PageSpeedProvider, and EgressService

This commit is contained in:
Alex Holliday committed 2026-09-16 11:37:29 -07:00
1 parent c66872c7f4
commit 50d0721822
7 files changed
+103 -19

No files matched your search

@@ -23,16 +23,27 @@ export interface ISettingsService {
getSettings(): EnvConfig;
areStatusPageThemesEnabled(): boolean;
getDBSettings(): Promise<Settings>;
getCachedDBSettings(): Promise<Settings>;
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
removeEgressNotification(notificationId: string): Promise<void>;
}
const DB_SETTINGS_CACHE_TTL_MS = 60_000;
type CachedSettings = {
value: Promise<Settings>;
expiresAt: number;
};
export class SettingsService implements ISettingsService {
static SERVICE_NAME = SERVICE_NAME;
private settings: EnvConfig;
private settingsRepository: ISettingsRepository | null = null;
private dbSettingsCache: CachedSettings | null = null;
constructor(env: ValidatedEnv) {
constructor(
env: ValidatedEnv,
private dbSettingsTTLMs: number = DB_SETTINGS_CACHE_TTL_MS
) {
this.settings = {
jwtSecret: env.JWT_SECRET,
jwtTTL: env.TOKEN_TTL as StringValue,
@@ -76,11 +87,14 @@ export class SettingsService implements ISettingsService {
}
updateDbSettings = async (newSettings: SettingsUpdate) => {
return await this.getRepository().update(newSettings);
const updated = await this.getRepository().update(newSettings);
this.dbSettingsCache = null;
return updated;
};
removeEgressNotification = async (notificationId: string) => {
await this.getRepository().removeEgressNotification(notificationId);
this.dbSettingsCache = null;
};
getDBSettings = async () => {
@@ -100,4 +114,17 @@ export class SettingsService implements ISettingsService {
return settings;
};
getCachedDBSettings = async () => {
const hit = this.dbSettingsCache;
if (hit && hit.expiresAt > Date.now()) {
return hit.value;
}
const entry: CachedSettings = { value: this.getDBSettings(), expiresAt: Date.now() + this.dbSettingsTTLMs };
this.dbSettingsCache = entry;
entry.value.catch(() => {
if (this.dbSettingsCache === entry) this.dbSettingsCache = null;
});
return entry.value;
};
}
+2 -2
View File
@@ -173,7 +173,7 @@ export class EgressService implements IEgressService {
private assess = async (): Promise<EgressAssessment | null> => {
try {
const settings = await this.settingsService.getDBSettings();
const settings = await this.settingsService.getCachedDBSettings();
if (!settings.egressCheckEnabled) {
return "disabled";
}
@@ -250,7 +250,7 @@ export class EgressService implements IEgressService {
// Runs on the queue at the configured interval while degraded. Errors propagate so the queue records
// the failure and retries with its usual backoff.
checkRecovery = async (job: Job): Promise<void> => {
const settings = await this.settingsService.getDBSettings();
const settings = await this.settingsService.getCachedDBSettings();
const state = await this.egressStateRepository.findSingleton();
if (!settings.egressCheckEnabled || state.status !== "degraded") {
// Feature switched off, recovery recorded by another worker, or state reset: nothing left to poll for.
@@ -22,7 +22,7 @@ export class PageSpeedProvider implements IStatusProvider<PageSpeedStatusPayload
const { url, strategy } = monitor;
try {
if (!url) throw new Error("URL is required for PageSpeed monitor");
const dbSettings = await this.settingsService.getDBSettings();
const dbSettings = await this.settingsService.getCachedDBSettings();
const apiKey = dbSettings?.pagespeedApiKey;
const resolvedStrategy = strategy ?? DefaultPageSpeedStrategy;
let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(
+1 -1
View File
@@ -73,7 +73,7 @@ export class ProxyResolver implements IProxyResolver {
};
private resolveInherit = async (monitor: Monitor): Promise<string | undefined> => {
const settings = await this.getCached("settings", () => this.settingsService.getDBSettings());
const settings = await this.settingsService.getCachedDBSettings();
if (!settings.globalProxyEnabled) {
return undefined;
}
+1 -1
View File
@@ -199,7 +199,7 @@ export class WorkerHelper implements IWorkerHelper {
getCleanupRetentionJob = () => {
return async () => {
try {
const settings = await this.settingsService.getDBSettings();
const settings = await this.settingsService.getCachedDBSettings();
const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS
@@ -27,7 +27,7 @@ const makeProxy = (overrides?: Partial<Proxy>): Proxy =>
const createResolver = (overrides?: Record<string, any>) => {
const defaults = {
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(makeProxy()) },
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) },
settingsService: { getCachedDbSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) },
logger: createMockLogger(),
ttlMs: 60_000,
...overrides,
@@ -46,7 +46,7 @@ describe("ProxyResolver", () => {
expect(result).toBeUndefined();
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled();
expect(defaults.settingsService.getCachedDbSettings).not.toHaveBeenCalled();
});
it("returns undefined for proxyMode 'none' without calling settings", async () => {
@@ -55,7 +55,7 @@ describe("ProxyResolver", () => {
const result = await resolver.resolve(makeMonitor({ proxyMode: "none" }));
expect(result).toBeUndefined();
expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled();
expect(defaults.settingsService.getCachedDbSettings).not.toHaveBeenCalled();
});
// ── custom mode ───────────────────────────────────────────────────────────
@@ -94,7 +94,7 @@ describe("ProxyResolver", () => {
it("returns undefined when the global proxy is disabled, without a repo call", async () => {
const { resolver, defaults } = createResolver({
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) },
settingsService: { getCachedDbSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
@@ -114,7 +114,7 @@ describe("ProxyResolver", () => {
it("returns undefined when the global proxy is enabled but globalProxyId is null, without a repo call", async () => {
const { resolver, defaults } = createResolver({
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) },
settingsService: { getCachedDbSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
@@ -140,7 +140,7 @@ describe("ProxyResolver", () => {
const result = await resolver.resolve(makeMonitor({ proxyMode: "garbage" as Monitor["proxyMode"] }));
expect(result).toBe("http://proxy.example.com:8080");
expect(defaults.settingsService.getDBSettings).toHaveBeenCalled();
expect(defaults.settingsService.getCachedDbSettings).toHaveBeenCalled();
});
// ── never throws ──────────────────────────────────────────────────────────
@@ -158,7 +158,7 @@ describe("ProxyResolver", () => {
it("returns undefined and warns when the settings service throws", async () => {
const { resolver, defaults } = createResolver({
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockRejectedValue(new Error("db down")) },
settingsService: { getCachedDbSettings: jest.fn<() => Promise<any>>().mockRejectedValue(new Error("db down")) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
@@ -175,7 +175,6 @@ describe("ProxyResolver", () => {
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1);
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
});
@@ -185,7 +184,6 @@ describe("ProxyResolver", () => {
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(2);
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(2);
});
@@ -209,7 +207,6 @@ describe("ProxyResolver", () => {
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
]);
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1);
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
});
@@ -47,9 +47,9 @@ const createSettingsRepo = () =>
deleteLegacy: jest.fn().mockResolvedValue(true),
}) as unknown as jest.Mocked<ISettingsRepository>;
const createService = (envOverrides?: Partial<ValidatedEnv>) => {
const createService = (envOverrides?: Partial<ValidatedEnv>, dbSettingsTTLMs?: number) => {
const env = makeEnv(envOverrides);
const service = new SettingsService(env);
const service = new SettingsService(env, dbSettingsTTLMs);
const settingsRepository = createSettingsRepo();
service.setRepository(settingsRepository);
return { service, settingsRepository, env };
@@ -172,6 +172,66 @@ describe("SettingsService", () => {
});
});
// ── getCachedDbSettings ─────────────────────────────────────────────────
describe("getCachedDbSettings", () => {
it("serves a second call within the TTL from cache", async () => {
const { service, settingsRepository } = createService();
const first = await service.getCachedDbSettings();
const second = await service.getCachedDbSettings();
expect(second).toBe(first);
expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(1);
});
it("refetches after the TTL expires", async () => {
const { service, settingsRepository } = createService(undefined, 0);
await service.getCachedDbSettings();
await service.getCachedDbSettings();
expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2);
});
it("shares one in-flight fetch between concurrent calls", async () => {
const { service, settingsRepository } = createService();
await Promise.all([service.getCachedDbSettings(), service.getCachedDbSettings(), service.getCachedDbSettings()]);
expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(1);
});
it("does not cache a rejected fetch, the next call retries", async () => {
const { service, settingsRepository } = createService();
(settingsRepository.findSingleton as jest.Mock).mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeSettings());
await expect(service.getCachedDbSettings()).rejects.toThrow("db down");
await expect(service.getCachedDbSettings()).resolves.toEqual(makeSettings());
expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2);
});
it("refetches after updateDbSettings so the write is visible immediately", async () => {
const { service, settingsRepository } = createService();
await service.getCachedDbSettings();
await service.updateDbSettings({ checkTTL: 60 });
await service.getCachedDbSettings();
expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2);
});
it("refetches after removeEgressNotification", async () => {
const { service, settingsRepository } = createService();
await service.getCachedDbSettings();
await service.removeEgressNotification("notif-1");
await service.getCachedDbSettings();
expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2);
});
});
// ── updateDbSettings ────────────────────────────────────────────────────
describe("updateDbSettings", () => {