openvibers/openvibe.live Commits

Channel 'Most watched': its three Media reads in parallel and the answer kept a minute per channel (p95 was 0.77 s, sequential reads on every offline channel view) (#196)

OP OpenVibers committed 27 days ago · 9f04773766 · 2 files, +74 −7

Changes

ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex e35b2042..ec35d672 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -580,17 +580,23 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
});
// ── Most-popular recent VOD + clip (offline-screen "explore" cards) ──
++// One answer per channel for a minute, its three Media reads in parallel: they ran one after another on every view
++// of an offline channel (p95 0.77 s, 2026-10-17). The answer is the same for every visitor (no auth on this route).
++const POPULAR_TTL_MS = 60 * 1000;
++const _popularCache = new Map(); // user id → { at, data }
router.get('/channel/:username/popular', async (req, res) => {
try {
const user = await db.getUserByUsername(req.params.username);
if (!user) return res.status(404).json({ error: 'Not found' });
-- let vods = [], clips = [];
++ const hit = _popularCache.get(user.id);
++ if (hit && Date.now() - hit.at < POPULAR_TTL_MS) return res.json(hit.data);
const media = require('../media-client');
-- try { const r = await media.listVods({ user_id: user.id, order: 'views', limit: 12 }); vods = r?.vods || (Array.isArray(r) ? r : []); } catch { /* */ }
-- try { const r = await media.listClips({ channel_user_id: user.id, order: 'views', limit: 12 }); clips = r?.clips || (Array.isArray(r) ? r : []); } catch { /* */ }
-- let ranges = null;
-- try { ranges = await require('../media-proxy/lookups').topContentRanges(user); } catch { /* */ }
-- res.json({
++ const [vods, clips, ranges] = await Promise.all([
++ media.listVods({ user_id: user.id, order: 'views', limit: 12 }).then((r) => r?.vods || (Array.isArray(r) ? r : [])).catch(() => []),
++ media.listClips({ channel_user_id: user.id, order: 'views', limit: 12 }).then((r) => r?.clips || (Array.isArray(r) ? r : [])).catch(() => []),
++ Promise.resolve().then(() => require('../media-proxy/lookups').topContentRanges(user)).catch(() => null),
++ ]);
++ const data = {
// Singular kept for back-compat; arrays let the offline screen fill the space.
vod: vods[0] || null,
clip: clips[0] || null,
@@@ -598,7 +604,10 @@ router.get('/channel/:username/popular', async (req, res) => {
clips,
// Top VOD + clip per time window (week/month/all) for the compact offline cycler.
ranges,
-- });
++ };
++ if (_popularCache.size >= 500) _popularCache.clear();
++ _popularCache.set(user.id, { at: Date.now(), data });
++ res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to get popular content' });
}
ddiff --git a/test/channel-popular-cache.test.js b/test/channel-popular-cache.test.js
nnew file mode 100644
iindex 00000000..f3957454
---- /dev/null
++++ b/test/channel-popular-cache.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * GET /api/streams/channel/:username/popular (the offline channel's "Most watched"): its three Media reads run in
++ * parallel and the answer is kept for a minute per channel (they ran one after another on every view: p95 0.77 s,
++ * 2026-10-17). A Media read that fails leaves its part empty, not the whole answer.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/channel-popular-cache.test.js
++ */
++const assert = require('assert');
++const http = require('http');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ await db.createUser({ username: 'popstar', email: 'pop@x', password_hash: 'x', display_name: 'popstar', stream_key: 'p'.repeat(32) });
++
++ const media = require('../server/media-client');
++ const lookups = require('../server/media-proxy/lookups');
++ const calls = { vods: 0, clips: 0, ranges: 0 };
++ let inFlight = 0, maxInFlight = 0;
++ const slow = (fn) => async (...a) => {
++ inFlight++; maxInFlight = Math.max(maxInFlight, inFlight);
++ await new Promise((r) => setTimeout(r, 60));
++ inFlight--;
++ return fn(...a);
++ };
++ media.listVods = slow(() => { calls.vods++; return { vods: [{ id: 1, title: 'big night' }] }; });
++ media.listClips = slow(() => { calls.clips++; throw new Error('Media down'); });
++ lookups.topContentRanges = slow(() => { calls.ranges++; return { week: null, month: null, all: null }; });
++
++ const express = require('express');
++ const app = express();
++ app.use('/api/streams', require('../server/streaming/routes'));
++ const server = http.createServer(app).listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const get = async (p) => { const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`); return { status: r.status, json: await r.json() }; };
++
++ const first = await get('/api/streams/channel/popstar/popular');
++ assert.strictEqual(first.status, 200);
++ assert.strictEqual(first.json.vod.title, 'big night');
++ assert.deepStrictEqual(first.json.clips, [], 'a failed Media read leaves its part empty');
++ assert.ok(first.json.ranges, 'the other reads still answer');
++ assert.strictEqual(maxInFlight, 3, 'the three Media reads run in parallel');
++
++ await get('/api/streams/channel/popstar/popular');
++ assert.deepStrictEqual(calls, { vods: 1, clips: 1, ranges: 1 }, 'the second view within a minute is answered from the cache');
++ assert.strictEqual((await get('/api/streams/channel/nobody_here/popular')).status, 404);
++
++ server.close();
++ console.log = quiet;
++ console.log('channel popular: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });