perf(backend): cache global_settings reads in DatabaseService (#814)

getGlobalSettings() runs a SELECT * on every call and is hit from 22
files, including the auth middleware (every authenticated request),
the WebSocket upgrade handler (every connection), and the debug-mode
gate (every diagnostic log line). Cache the result inside the service
on first read and invalidate on updateGlobalSetting().

The cached snapshot is Object.freeze'd and the public return type is
now Readonly<Record<string, string>> so accidental mutations are
caught at compile time. The settings GET handler that delete'd private
keys now takes a defensive shallow copy first.

The 5-second TTL cache in utils/debug.ts is now redundant and removed;
the service-level cache is strictly fresher (invalidates on write
rather than going stale for up to 5s).
This commit is contained in:
Anso
2026-04-27 23:45:25 -04:00
committed by GitHub
parent 502ee83438
commit 836e384d17
3 changed files with 25 additions and 23 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ export const settingsRouter = Router();
settingsRouter.get('/', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const settings = { ...DatabaseService.getInstance().getGlobalSettings() };
for (const key of PRIVATE_SETTINGS_KEYS) {
delete settings[key];
}
+19 -5
View File
@@ -470,6 +470,13 @@ export interface ScanSummary {
export class DatabaseService {
private static instance: DatabaseService;
private db: Database.Database;
// Cache of the global_settings table, populated on first read and
// invalidated by updateGlobalSetting(). Hot paths (auth middleware,
// WS upgrade, the audit-log debug gate) read this on every request,
// so the round-trip to SQLite is worth eliminating. Assumes this
// process is the sole writer to global_settings; sidecar tools that
// edit the row directly will not invalidate the cache.
private cachedGlobalSettings: Readonly<Record<string, string>> | null = null;
private constructor() {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
@@ -497,6 +504,11 @@ export class DatabaseService {
this.migrateAgentsAndNotificationsNodeId();
this.migratePolicyEvaluationColumn();
this.migrateNotificationCategory();
// Reset the cache once at end of constructor in case any migration
// populated it via getGlobalSettings() and a subsequent migration
// changed the underlying rows.
this.cachedGlobalSettings = null;
}
public static getInstance(): DatabaseService {
@@ -1330,18 +1342,20 @@ export class DatabaseService {
// --- Global Settings ---
public getGlobalSettings(): Record<string, string> {
public getGlobalSettings(): Readonly<Record<string, string>> {
if (this.cachedGlobalSettings) return this.cachedGlobalSettings;
const stmt = this.db.prepare('SELECT * FROM global_settings');
const rows = stmt.all() as Array<{ key: string; value: string }>;
const settings: Record<string, string> = {};
stmt.all().forEach((row: any) => {
settings[row.key] = row.value;
});
return settings;
for (const row of rows) settings[row.key] = row.value;
this.cachedGlobalSettings = Object.freeze(settings);
return this.cachedGlobalSettings;
}
public updateGlobalSetting(key: string, value: string): void {
const stmt = this.db.prepare('INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)');
stmt.run(key, value);
this.cachedGlobalSettings = null;
}
// --- System State (operational/runtime values - not user-defined config) ---
+5 -17
View File
@@ -1,30 +1,18 @@
/**
* Shared diagnostic logging gate.
*
* Reads `developer_mode` from the global settings, cached for a short
* window so hot paths (per-request, per-log-line) do not hit SQLite
* on every call.
* Shared diagnostic logging gate. Reads `developer_mode` from
* DatabaseService, which caches the global_settings snapshot internally
* and invalidates on write, so hot-path callers can query freely.
*/
let cachedValue = false;
let cacheExpiry = 0;
const CACHE_TTL_MS = 5_000;
export function isDebugEnabled(): boolean {
const now = Date.now();
if (now < cacheExpiry) return cachedValue;
try {
// Dynamic require avoids circular-dependency issues when this
// utility is imported from services that DatabaseService itself
// depends on, and prevents SQLite side effects during tests.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseService } = require('../services/DatabaseService');
cachedValue = DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
} catch {
cachedValue = false;
return false;
}
cacheExpiry = now + CACHE_TTL_MS;
return cachedValue;
}