openvibers/openvibe.live Commits

/internal/analytics-summary: one computation per window per 10 minutes, shared by concurrent callers (the full stats behind a few totals took ~4 s of database time at p95) (#215)

OP OpenVibers committed 27 days ago · 6c245b1faf · 3 files, +70 −2

Changes

ddiff --git a/server/analytics/summary-cache.js b/server/analytics/summary-cache.js
nnew file mode 100644
iindex 00000000..9ff977fc
---- /dev/null
++++ b/server/analytics/summary-cache.js
@@@ -0,0 +1,25 @@
++'use strict';
++/**
++ * The totals behind GET /internal/analytics-summary (Network orders its navigation by real use). The full stats they
++ * come from took ~4 s of database time (p95, 2026-10-17) for a few numbers Network reads every so often, so each
++ * window is computed at most once per `ttlMs`, and concurrent callers share one computation. A failed computation is
++ * not kept: the next call tries again.
++ *
++ * const summary = createSummaryCache((days) => analytics.getStats({ days }));
++ * await summary(7) // → { … } (the stats' `summary`, or {})
++ */
++function createSummaryCache(getStats, { ttlMs = 600_000, now = () => Date.now() } = {}) {
++ const entries = new Map(); // days -> { at, promise }
++ return function summary(days) {
++ let e = entries.get(days);
++ if (!e || now() - e.at > ttlMs) {
++ e = { at: now(), promise: Promise.resolve().then(() => getStats(days)).then((st) => (st && st.summary) || {}) };
++ const mine = e;
++ e.promise.catch(() => { if (entries.get(days) === mine) entries.delete(days); });
++ entries.set(days, e);
++ }
++ return e.promise;
++ };
++}
++
++module.exports = { createSummaryCache };
ddiff --git a/server/index.js b/server/index.js
iindex 0fa78741..4bc60084 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -696,11 +696,12 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
// Internal (server-to-server) routes — allow openvibe.network to call into this service
// Summary numbers for the Network's navigation service (ordering sites by real use).
// A service token with live.analytics.read (loopback only); returns totals, never rows.
++// One computation per window per 10 minutes (./analytics/summary-cache): the full stats take ~4 s of database time.
++const analyticsSummary = require('./analytics/summary-cache').createSummaryCache((days) => analytics.getStats({ days }));
app.get('/internal/analytics-summary', require('./net/service-guard').guard('live.analytics.read'), async (req, res) => {
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
-- const st = await analytics.getStats({ days }) || {};
-- res.json({ ok: true, summary: st.summary || {} });
++ res.json({ ok: true, summary: await analyticsSummary(days) });
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/test/analytics-summary-cache.test.js b/test/analytics-summary-cache.test.js
nnew file mode 100644
iindex 00000000..b307082c
---- /dev/null
++++ b/test/analytics-summary-cache.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++/**
++ * GET /internal/analytics-summary computes Live's full stats for a few totals (~4 s of database time at p95). The
++ * summary cache (server/analytics/summary-cache.js): one computation per window per 10 minutes, shared by concurrent
++ * callers; a failed computation is not kept.
++ *
++ * node test/analytics-summary-cache.test.js
++ */
++const assert = require('assert');
++const { createSummaryCache } = require('../server/analytics/summary-cache');
++
++(async () => {
++ let t = 0, calls = 0, fail = false;
++ const summary = createSummaryCache(async (days) => {
++ calls++;
++ if (fail) throw new Error('db down');
++ return { summary: { days, views: calls } };
++ }, { ttlMs: 600_000, now: () => t });
++
++ // Concurrent callers share one computation.
++ const [a, b] = await Promise.all([summary(7), summary(7)]);
++ assert.deepStrictEqual([a, b, calls], [{ days: 7, views: 1 }, { days: 7, views: 1 }, 1]);
++ // Within ten minutes: the same answer, no new computation; another window is its own.
++ t += 599_000;
++ assert.deepStrictEqual(await summary(7), { days: 7, views: 1 });
++ assert.deepStrictEqual(await summary(30), { days: 30, views: 2 });
++ assert.strictEqual(calls, 2);
++ // After ten minutes: computed again.
++ t += 2_000;
++ assert.deepStrictEqual(await summary(7), { days: 7, views: 3 });
++ // A failure is not kept: the next call tries again.
++ t += 700_000;
++ fail = true;
++ await assert.rejects(summary(7), /db down/);
++ fail = false;
++ assert.deepStrictEqual(await summary(7), { days: 7, views: 5 });
++ // No summary in the stats: an empty object.
++ const empty = createSummaryCache(async () => ({}));
++ assert.deepStrictEqual(await empty(7), {});
++
++ console.log('analytics summary cache: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });