perf(slideshow): cache global settings to cut /state DB reads (PR #646 review)

Each /state poll fired ~10 getAppSetting reads to resolve the watermark/fit;
a leaked link x N tabs amplified that linearly (review concern 2). Add a
5s-TTL cached bundle (utils/slideshowGlobals) for the global slideshow_* +
branding-logo settings, invalidated on PUT /admin/settings/slideshow so admin
live-edit stays instant. slideshowSettings now does ~2 reads per poll (event
row + photo count) on a cache hit. Also documents the frontend
optimistic-default nit.
This commit is contained in:
Luca
2026-06-21 02:53:34 +02:00
parent e36b3309ca
commit a995131f42
5 changed files with 90 additions and 24 deletions
@@ -25,6 +25,7 @@ const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
const SLUG = 'wedding-test';
const TOKEN = 'show-tok-abcdef';
@@ -89,6 +90,7 @@ describe('public Live Slideshow routes', () => {
await db('app_settings').del();
await db('feature_flags').del();
invalidateFeatureFlagCache();
invalidateSlideshowGlobals();
await setFlag(db, 'slideshow', true);
});
+3
View File
@@ -345,6 +345,9 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
for (const u of updates) {
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
}
// Drop the slideshow-globals cache so a running projector picks up the
// change on its next poll rather than after the 5s TTL.
require('../utils/slideshowGlobals').invalidateSlideshowGlobals();
res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) });
} catch (error) {
console.error('Slideshow settings save error:', error);
+24 -24
View File
@@ -28,7 +28,7 @@ const { getStorage } = require('../services/storage');
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
// Read globals from app_settings (the real table) — settingsService.getSetting
// queries a non-existent `settings` table and throws.
const { getAppSetting } = require('../utils/appSettings');
const { getSlideshowGlobals } = require('../utils/slideshowGlobals');
const { isFeatureEnabled } = require('../middleware/requireFeatureFlag');
const fs = require('fs');
@@ -269,48 +269,48 @@ async function resolveSlideshow(slug, token) {
// from the chosen source so the kiosk renders it without knowing about
// branding/event internals; null url = nothing to overlay.
async function slideshowSettings(event) {
// The global look/fit (Settings → Slideshow) + branding logo URLs come from a
// short-TTL cached bundle so a 3s projector poll doesn't re-fire ~10 settings
// reads each time (PR #646 review, concern 2).
const g = await getSlideshowGlobals();
// Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE
// globally (Settings → Slideshow); it is NOT duplicated per event. The only
// per-event control is whether the watermark shows: `show_watermark` NULL
// inherits the global enabled flag, true/false force it on/off.
// globally; it is NOT duplicated per event. The only per-event control is
// whether the watermark shows: `show_watermark` NULL inherits the global
// enabled flag, true/false force it on/off.
const wm = event.show_watermark;
const inherit = (wm === null || wm === undefined);
const enabled = inherit
? (await getAppSetting('slideshow_watermark_enabled', false)) === true
: (wm === true || wm === 1 || wm === '1');
const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1');
let watermark = null;
if (enabled) {
const source = await getAppSetting('slideshow_watermark_source', 'logo');
const position = await getAppSetting('slideshow_watermark_position', 'bottom-right');
const opacity = await getAppSetting('slideshow_watermark_opacity', 60);
const style = await getAppSetting('slideshow_watermark_style', 'white');
const size = await getAppSetting('slideshow_watermark_size', 12);
// Resolve the chosen logo to a URL. Branding assets come from settings;
// the event source uses the event's own hero logo.
let url;
if (source === 'event') {
if (g.watermark_source === 'event') {
url = event.hero_logo_url || null;
} else if (source === 'logo_dark') {
url = await getAppSetting('branding_logo_url_dark', null);
} else if (source === 'favicon') {
url = await getAppSetting('branding_favicon_url', null);
} else if (g.watermark_source === 'logo_dark') {
url = g.branding_logo_url_dark;
} else if (g.watermark_source === 'favicon') {
url = g.branding_favicon_url;
} else {
url = await getAppSetting('branding_logo_url', null);
url = g.branding_logo_url;
}
if (url) {
watermark = { url, position: position || 'bottom-right', opacity: opacity ?? 60, style: style || 'white', size: size ?? 12 };
watermark = {
url,
position: g.watermark_position,
opacity: g.watermark_opacity,
style: g.watermark_style,
size: g.watermark_size,
};
}
}
// Image fit is a global setting (Settings → Slideshow): 'cover' fills + crops,
// 'contain' shows the whole image with black bars (no crop).
const fitRaw = await getAppSetting('slideshow_fit', 'cover');
const fit = fitRaw === 'contain' ? 'contain' : 'cover';
return {
interval_ms: event.show_interval_ms || 5000,
transition: event.show_transition || 'crossfade',
transition_ms: event.show_transition_ms || 800,
colorfilter: event.show_colorfilter || 'none',
fit,
fit: g.fit,
watermark,
};
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Cached read of the global Live-Slideshow settings (Settings → Slideshow) +
* the branding logo URLs the watermark resolves against.
*
* Why: a running projector polls `/show/:token/state` every ~3s, and each poll
* resolved the watermark/fit by firing ~710 individual `getAppSetting` reads.
* A leaked link × N tabs amplifies that linearly (PR #646 review, concern 2).
* These globals change only via `PUT /admin/settings/slideshow`, so we cache
* the whole bundle with a short TTL and invalidate on write — admin live-edit
* stays effectively instant, and steady-state polls drop to ~0 settings reads.
*/
const { getAppSetting } = require('./appSettings');
const TTL_MS = 5000;
let cache = null; // { at, val }
async function getSlideshowGlobals() {
const now = Date.now();
if (cache && now - cache.at < TTL_MS) return cache.val;
const [
enabled, source, position, opacity, style, size, fit,
logo, logoDark, favicon,
] = await Promise.all([
getAppSetting('slideshow_watermark_enabled', false),
getAppSetting('slideshow_watermark_source', 'logo'),
getAppSetting('slideshow_watermark_position', 'bottom-right'),
getAppSetting('slideshow_watermark_opacity', 60),
getAppSetting('slideshow_watermark_style', 'white'),
getAppSetting('slideshow_watermark_size', 12),
getAppSetting('slideshow_fit', 'cover'),
getAppSetting('branding_logo_url', null),
getAppSetting('branding_logo_url_dark', null),
getAppSetting('branding_favicon_url', null),
]);
const val = {
watermark_enabled: enabled === true,
watermark_source: source || 'logo',
watermark_position: position || 'bottom-right',
watermark_opacity: opacity ?? 60,
watermark_style: style || 'white',
watermark_size: size ?? 12,
fit: fit === 'contain' ? 'contain' : 'cover',
branding_logo_url: logo || null,
branding_logo_url_dark: logoDark || null,
branding_favicon_url: favicon || null,
};
cache = { at: now, val };
return val;
}
/** Clear the cache — call after any write to the slideshow_* / branding logo settings. */
function invalidateSlideshowGlobals() {
cache = null;
}
module.exports = { getSlideshowGlobals, invalidateSlideshowGlobals };
@@ -23,6 +23,9 @@ import {
} from '../../services/slideshow.service';
import { WatermarkSourcePicker } from './WatermarkSourcePicker';
// Optimistic form state shown until the GET resolves; mirrors the backend
// defaults so the controls don't flicker on load (backend is the source of
// truth — these are just the pre-fetch placeholder).
const DEFAULTS: SlideshowGlobalDefaults = {
slideshow_fit: 'cover',
slideshow_interval_ms: 5000,