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:
@@ -25,6 +25,7 @@ const cookieParser = require('cookie-parser');
|
|||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||||
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
||||||
|
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
|
||||||
|
|
||||||
const SLUG = 'wedding-test';
|
const SLUG = 'wedding-test';
|
||||||
const TOKEN = 'show-tok-abcdef';
|
const TOKEN = 'show-tok-abcdef';
|
||||||
@@ -89,6 +90,7 @@ describe('public Live Slideshow routes', () => {
|
|||||||
await db('app_settings').del();
|
await db('app_settings').del();
|
||||||
await db('feature_flags').del();
|
await db('feature_flags').del();
|
||||||
invalidateFeatureFlagCache();
|
invalidateFeatureFlagCache();
|
||||||
|
invalidateSlideshowGlobals();
|
||||||
await setFlag(db, 'slideshow', true);
|
await setFlag(db, 'slideshow', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,9 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
|
|||||||
for (const u of updates) {
|
for (const u of updates) {
|
||||||
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
|
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) });
|
res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Slideshow settings save error:', error);
|
console.error('Slideshow settings save error:', error);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const { getStorage } = require('../services/storage');
|
|||||||
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
|
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
|
||||||
// Read globals from app_settings (the real table) — settingsService.getSetting
|
// Read globals from app_settings (the real table) — settingsService.getSetting
|
||||||
// queries a non-existent `settings` table and throws.
|
// queries a non-existent `settings` table and throws.
|
||||||
const { getAppSetting } = require('../utils/appSettings');
|
const { getSlideshowGlobals } = require('../utils/slideshowGlobals');
|
||||||
const { isFeatureEnabled } = require('../middleware/requireFeatureFlag');
|
const { isFeatureEnabled } = require('../middleware/requireFeatureFlag');
|
||||||
const fs = require('fs');
|
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
|
// from the chosen source so the kiosk renders it without knowing about
|
||||||
// branding/event internals; null url = nothing to overlay.
|
// branding/event internals; null url = nothing to overlay.
|
||||||
async function slideshowSettings(event) {
|
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
|
// Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE
|
||||||
// globally (Settings → Slideshow); it is NOT duplicated per event. The only
|
// globally; it is NOT duplicated per event. The only per-event control is
|
||||||
// per-event control is whether the watermark shows: `show_watermark` NULL
|
// whether the watermark shows: `show_watermark` NULL inherits the global
|
||||||
// inherits the global enabled flag, true/false force it on/off.
|
// enabled flag, true/false force it on/off.
|
||||||
const wm = event.show_watermark;
|
const wm = event.show_watermark;
|
||||||
const inherit = (wm === null || wm === undefined);
|
const inherit = (wm === null || wm === undefined);
|
||||||
const enabled = inherit
|
const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1');
|
||||||
? (await getAppSetting('slideshow_watermark_enabled', false)) === true
|
|
||||||
: (wm === true || wm === 1 || wm === '1');
|
|
||||||
let watermark = null;
|
let watermark = null;
|
||||||
if (enabled) {
|
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;
|
// Resolve the chosen logo to a URL. Branding assets come from settings;
|
||||||
// the event source uses the event's own hero logo.
|
// the event source uses the event's own hero logo.
|
||||||
let url;
|
let url;
|
||||||
if (source === 'event') {
|
if (g.watermark_source === 'event') {
|
||||||
url = event.hero_logo_url || null;
|
url = event.hero_logo_url || null;
|
||||||
} else if (source === 'logo_dark') {
|
} else if (g.watermark_source === 'logo_dark') {
|
||||||
url = await getAppSetting('branding_logo_url_dark', null);
|
url = g.branding_logo_url_dark;
|
||||||
} else if (source === 'favicon') {
|
} else if (g.watermark_source === 'favicon') {
|
||||||
url = await getAppSetting('branding_favicon_url', null);
|
url = g.branding_favicon_url;
|
||||||
} else {
|
} else {
|
||||||
url = await getAppSetting('branding_logo_url', null);
|
url = g.branding_logo_url;
|
||||||
}
|
}
|
||||||
if (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 {
|
return {
|
||||||
interval_ms: event.show_interval_ms || 5000,
|
interval_ms: event.show_interval_ms || 5000,
|
||||||
transition: event.show_transition || 'crossfade',
|
transition: event.show_transition || 'crossfade',
|
||||||
transition_ms: event.show_transition_ms || 800,
|
transition_ms: event.show_transition_ms || 800,
|
||||||
colorfilter: event.show_colorfilter || 'none',
|
colorfilter: event.show_colorfilter || 'none',
|
||||||
fit,
|
fit: g.fit,
|
||||||
watermark,
|
watermark,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ~7–10 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';
|
} from '../../services/slideshow.service';
|
||||||
import { WatermarkSourcePicker } from './WatermarkSourcePicker';
|
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 = {
|
const DEFAULTS: SlideshowGlobalDefaults = {
|
||||||
slideshow_fit: 'cover',
|
slideshow_fit: 'cover',
|
||||||
slideshow_interval_ms: 5000,
|
slideshow_interval_ms: 5000,
|
||||||
|
|||||||
Reference in New Issue
Block a user