openvibers/openvibe.reviews Commits
The home's figures are kept five minutes, then refreshed in the background (#194)
Changes
@@@ -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@@@ -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,@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -170,7 +170,7 @@ function createApp({ config, telemetry = null, svc, viewers, platform, sources,
@@ -170,7 +170,7 @@ function createApp({ config, telemetry = null, svc, viewers, platform, sources,
app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
app.post('/e/:slug/correct', limiter(60 * 60000, 20));
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
// 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
// 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
// page's own render (signed in or out) and only a twin request reaches the agent layer. The manifest
@@ -21,6 +21,8 @@ function load(env = process.env) {
@@ -21,6 +21,8 @@ function load(env = process.env) {
serviceId: 'reviews',
serviceId: 'reviews',
nodeEnv,
nodeEnv,
isProduction,
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,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
baseUrl,
baseUrl,
@@ -9,10 +9,36 @@
@@ -9,10 +9,36 @@
* critical, the newest grade on record, the borough and cuisine
* critical, the newest grade on record, the borough and cuisine
* mostHazardous(n) buildings by NYC HPD's open class C (immediately hazardous) violations
* mostHazardous(n) buildings by NYC HPD's open class C (immediately hazardous) violations
* mostComplaints(n) companies by the complaints consumers sent the CFPB
* 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 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 = {
const q = {
stats: db.prepare(`SELECT (SELECT COUNT(*) FROM review_entities WHERE state = 'active') AS pages,
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,
(SELECT COUNT(*) FROM review_records r WHERE ${ACTIVE_RECORD}) AS records,
@@ -44,7 +70,7 @@ function createHome({ db }) {
@@ -44,7 +70,7 @@ function createHome({ db }) {
};
};
const n = (v) => (v == null ? null : Number(v));
const n = (v) => (v == null ? null : Number(v));
const lim = (x, d) => Math.min(50, Math.max(1, Number(x) || d));
const lim = (x, d) => Math.min(50, Math.max(1, Number(x) || d));
- return {
+ const api = {
async stats() {
async stats() {
const r = (await q.stats.get()) || {};
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 };
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 }) {
@@ -59,6 +85,8 @@ function createHome({ db }) {
return (await q.complaints.all(lim(limit, 6))).map((x) => ({ ...x, complaints: n(x.complaints) }));
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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); });