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)
Changes
nnew file mode 100644@@@ -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 };@@@ -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 }); }});nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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 };
@@ -696,11 +696,12 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
@@ -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
// 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).
// 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.
// 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) => {
app.get('/internal/analytics-summary', require('./net/service-guard').guard('live.analytics.read'), async (req, res) => {
try {
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
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 }); }
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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); });