openvibers/openvibe.reviews Commits

The home's figures are kept five minutes, then refreshed in the background (#194)

OP OpenVibers committed 11 days ago · 3417c60e08 · 4 files, +93 −4

The home counted every review record on each request: 230 ms of a 150-250
ms origin time, and one request took 15 s through Cloudflare on 2026-11-02.
The counts and the three record lists change with each day's import, so in
production each answer is kept five minutes and then served at once while
one refresh runs (up to an hour; a failed refresh keeps the old answer).
Off outside production unless REVIEWS_HOME_CACHE_MS is set, so tests see
their own writes.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 154f852..7307cfd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -170,7 +170,7 @@ function createApp({ config, telemetry = null, svc, viewers, platform, sources,
app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
app.post('/e/:slug/correct', limiter(60 * 60000, 20));
-- const pages = createPages({ svc, platform, sync, viewers, config, log, limits, home: createHome({ db }) });
++ const pages = createPages({ svc, platform, sync, viewers, config, log, limits, home: createHome({ db, cacheMs: config.homeCacheMs }) });
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
// public pages. Mounted before the page routes but gated to twin requests only, so a browser keeps the
// page's own render (signed in or out) and only a twin request reaches the agent layer. The manifest
ddiff --git a/server/config.js b/server/config.js
iindex c717386..44f4444 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -21,6 +21,8 @@ function load(env = process.env) {
serviceId: 'reviews',
nodeEnv,
isProduction,
++ // The home's counts and lists, kept this long (server/reviews/home.js); off outside production unless set.
++ homeCacheMs: Math.max(0, int(env.REVIEWS_HOME_CACHE_MS, isProduction ? 5 * 60 * 1000 : 0)),
port,
host: env.HOST || '127.0.0.1',
baseUrl,
ddiff --git a/server/reviews/home.js b/server/reviews/home.js
iindex 73730a5..cf7ec10 100644
---- a/server/reviews/home.js
++++ b/server/reviews/home.js
@@@ -9,10 +9,36 @@
* critical, the newest grade on record, the borough and cuisine
* mostHazardous(n) buildings by NYC HPD's open class C (immediately hazardous) violations
* mostComplaints(n) companies by the complaints consumers sent the CFPB
++ *
++ * With cacheMs (production: 5 minutes), each answer is kept that long and then served at once while one refresh runs
++ * in the background, for up to an hour (stale-while-revalidate). The counts walk every record (230 ms on 2026-11-02,
++ * most of the home's time at the origin) and change slowly; the lists change with each day's import.
*/
const ACTIVE_RECORD = "r.status = 'active'";
++const STALE_MS = 60 * 60 * 1000;
++
++/** fn's answers kept cacheMs per argument list; past it, the old one is served while one refresh runs. */
++function remember(fn, { cacheMs, now }) {
++ if (!(cacheMs > 0)) return fn;
++ const kept = new Map();
++ return async (...args) => {
++ const key = JSON.stringify(args);
++ const hit = kept.get(key);
++ const t = now();
++ if (hit && t - hit.at < cacheMs) return hit.value;
++ if (hit && t - hit.at < STALE_MS) {
++ if (!hit.refresh) {
++ hit.refresh = fn(...args).then((value) => { kept.set(key, { at: now(), value }); }, () => { hit.refresh = null; });
++ }
++ return hit.value;
++ }
++ const value = await fn(...args);
++ kept.set(key, { at: now(), value });
++ return value;
++ };
++}
--function createHome({ db }) {
++function createHome({ db, cacheMs = 0, now = () => Date.now() }) {
const q = {
stats: db.prepare(`SELECT (SELECT COUNT(*) FROM review_entities WHERE state = 'active') AS pages,
(SELECT COUNT(*) FROM review_records r WHERE ${ACTIVE_RECORD}) AS records,
@@@ -44,7 +70,7 @@ function createHome({ db }) {
};
const n = (v) => (v == null ? null : Number(v));
const lim = (x, d) => Math.min(50, Math.max(1, Number(x) || d));
-- return {
++ const api = {
async stats() {
const r = (await q.stats.get()) || {};
return { pages: n(r.pages) || 0, records: n(r.records) || 0, signals: n(r.signals) || 0, memberReviews: n(r.member_reviews) || 0, seen: n(r.seen) || 0 };
@@@ -59,6 +85,8 @@ function createHome({ db }) {
return (await q.complaints.all(lim(limit, 6))).map((x) => ({ ...x, complaints: n(x.complaints) }));
},
};
++ const keep = (fn) => remember(fn, { cacheMs, now });
++ return { stats: keep(api.stats), latestInspections: keep(api.latestInspections), mostHazardous: keep(api.mostHazardous), mostComplaints: keep(api.mostComplaints) };
}
--module.exports = { createHome };
++module.exports = { createHome, remember };
ddiff --git a/test/home-cache.test.js b/test/home-cache.test.js
nnew file mode 100644
iindex 0000000..1a363e5
---- /dev/null
++++ b/test/home-cache.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * The home's figures are kept (server/reviews/home.js remember): within cacheMs the kept answer, past it the kept
++ * answer at once while one refresh runs, and past an hour a fresh answer is waited for. Off (cacheMs 0) outside
++ * production, so a test that writes and reads the home sees its own writes.
++ *
++ * node test/home-cache.test.js
++ */
++const assert = require('assert');
++const { remember } = require('../server/reviews/home');
++const { load } = require('../server/config');
++
++(async () => {
++ let t = 0;
++ let calls = 0;
++ let release = null;
++ const slow = async (n) => { calls += 1; if (release === 'hold') await new Promise((r) => { release = r; }); return { n, call: calls }; };
++ const kept = remember(slow, { cacheMs: 300_000, now: () => t });
++
++ assert.deepStrictEqual(await kept(8), { n: 8, call: 1 }, 'the first answer is waited for');
++ t = 60_000;
++ assert.deepStrictEqual(await kept(8), { n: 8, call: 1 }, 'within five minutes: kept');
++ assert.deepStrictEqual(await kept(6), { n: 6, call: 2 }, 'another argument list is its own entry');
++
++ t = 400_000;
++ release = 'hold';
++ const stale = await kept(8);
++ assert.deepStrictEqual(stale, { n: 8, call: 1 }, 'past five minutes: the kept answer at once');
++ await kept(8);
++ assert.strictEqual(calls, 3, 'one refresh runs, not one per request');
++ release();
++ await new Promise((r) => setImmediate(r));
++ assert.deepStrictEqual(await kept(8), { n: 8, call: 3 }, 'then the refreshed answer');
++
++ t = 400_000 + 2 * 60 * 60 * 1000;
++ release = null;
++ assert.deepStrictEqual(await kept(8), { n: 8, call: 4 }, 'past an hour: a fresh answer is waited for');
++
++ // A refresh that fails keeps the old answer, and the next request tries again.
++ let fail = true;
++ let tries = 0;
++ const flaky = remember(async () => { tries += 1; if (fail && tries > 1) throw new Error('db away'); return tries; }, { cacheMs: 1000, now: () => t });
++ t = 0;
++ assert.strictEqual(await flaky(), 1);
++ t = 5000;
++ assert.strictEqual(await flaky(), 1, 'stale while the refresh fails');
++ await new Promise((r) => setImmediate(r));
++ fail = false;
++ assert.strictEqual(await flaky(), 1, 'still the old answer, and a new refresh starts');
++ await new Promise((r) => setImmediate(r));
++ assert.strictEqual(await flaky(), 3);
++
++ // Off outside production unless set; five minutes in production.
++ assert.strictEqual(remember(slow, { cacheMs: 0, now: () => t }), slow, 'cacheMs 0: the function itself');
++ assert.strictEqual(load({ NODE_ENV: 'test' }).homeCacheMs, 0);
++ assert.strictEqual(load({ NODE_ENV: 'production', DATABASE_URL: 'postgres://x/y', OV_OAUTH_CLIENT_SECRET: 'x' }).homeCacheMs, 300_000);
++ assert.strictEqual(load({ NODE_ENV: 'test', REVIEWS_HOME_CACHE_MS: '1000' }).homeCacheMs, 1000);
++ console.log('home cache: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });