openvibers/openvibe.mediahub Commits

Video's Live reads: serve a recent answer at once and refresh it behind the page (#262)

OP OpenVibers committed 11 days ago · 7e9ff291a5 · 2 files, +25 −0

When the 60 s cache ran out, the next visitor waited for Live: openvibe.video's home answered in
0.8-2.3 s on a miss against 0.15 s on a hit. An answer under ten minutes old is now returned at once
while one background refresh per URL replaces it; an older one is still fetched as before, and the
30-minute fallback when Live does not answer is unchanged.

Changes

ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex 622f4dc..cfe1c41 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -16,6 +16,10 @@ const CACHE_TTL_MS = 60_000;
// (production, 10-16: 23 "Live did not answer" in 12 h, at random times). A page then shows the last answer, up to
// this old, instead of "OpenVibe.Live did not answer".
const STALE_MAX_MS = 30 * 60_000;
++// An answer past its minute but under this age is served at once while it refreshes behind the page: when the minute ran
++// out, the next visitor waited for Live (openvibe.video's home: 0.8–2.3 s to first byte on a miss, 0.15 s on a hit,
++// 2026-11-02). One refresh per URL at a time.
++const SERVE_STALE_MS = 10 * 60_000;
const CACHE_MAX = 500;
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
@@@ -80,11 +84,20 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
return value;
}
++ const refreshing = new Map(); // url → the load that refreshes it
async function getJson(path) {
const url = `${config.video.liveUrl}${path}`;
const cached = hit(url);
if (cached !== undefined) return cached;
++ const entry = cache.get(url);
++ if (entry && Date.now() - entry.at < SERVE_STALE_MS) {
++ if (!refreshing.has(url)) refreshing.set(url, load(url).catch(() => {}).finally(() => refreshing.delete(url)));
++ return entry.value;
++ }
++ return load(url);
++ }
++ async function load(url) {
let res;
try {
res = await fetchImpl(url, {
@@@ -157,6 +170,8 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
}
return {
++ /** Every background refresh in flight has finished (tests). */
++ settled: () => Promise.all([...refreshing.values()]),
forgetItem,
/** The person's own view of one item (a private one they own or may moderate included); null when Live says 404. */
async vodAs(token, id) {
ddiff --git a/test/video-live-stale.test.js b/test/video-live-stale.test.js
iindex 7e0a8c1..c37b34a 100644
---- a/test/video-live-stale.test.js
++++ b/test/video-live-stale.test.js
@@@ -34,12 +34,22 @@ const { createLiveClient, LiveUnavailableError } = require('../server/video/live
now += 5 * 60_000;
mode = 'timeout';
assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'Live timing out: the last answer');
++ await client.settled();
mode = '502';
assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'Live failing: the last answer');
++ await client.settled();
assert.ok(warnings.every((w) => /serving its last answer/.test(w)), 'and the log says so');
mode = 'ok';
++ // Under ten minutes old, the last answer is served at once and refreshed behind the page; the next read has it.
++ assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'served at once while it refreshes');
++ await client.settled();
assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: calls }], 'Live back: a new answer');
++ const before = calls;
++ now += 2 * 60_000;
++ await Promise.all([client.feed({ type: 'all', limit: 24 }), client.feed({ type: 'all', limit: 24 }), client.feed({ type: 'all', limit: 24 })]);
++ await client.settled();
++ assert.strictEqual(calls, before + 1, 'one refresh for three readers of an expired answer');
now += 31 * 60_000;
mode = 'timeout';