openvibers/openvibe.mediahub Commits

Video: when Live does not answer, a page shows Live's last answer (up to 30 min) instead of an error (#85)

OP OpenVibers committed 28 days ago · ca6f8babb5 · 2 files, +76 −5

Changes

ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex cb23873..f00dc31 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -12,6 +12,10 @@
*/
const TIMEOUT_MS = 5000;
const CACHE_TTL_MS = 60_000;
++// Live waits on OpenVibe.Media for recordings, and Media can stall for seconds while it remuxes a live stream's DVR
++// (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;
const CACHE_MAX = 500;
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
@@@ -49,10 +53,26 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
cache.set(key, entry); // touch: this entry is now the most recent
return entry.value;
}
-- cache.delete(key);
++ // Expired entries stay (until STALE_MAX_MS) as the answer for when Live does not answer.
return undefined;
}
++ /** The last good answer for `key`, if not older than STALE_MAX_MS: shown instead of an error page. */
++ function stale(key) {
++ const entry = cache.get(key);
++ if (!entry || Date.now() - entry.at >= STALE_MAX_MS) return undefined;
++ return entry.value;
++ }
++ function unavailable(url, why) {
++ const old = stale(url);
++ if (old !== undefined) {
++ log.warn('[OpenVibe.Video] Live did not answer; serving its last answer', why);
++ return old;
++ }
++ log.warn('[OpenVibe.Video] Live did not answer', why);
++ throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
++ }
++
function remember(key, value) {
cache.delete(key);
cache.set(key, { at: Date.now(), value });
@@@ -75,18 +95,17 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (err) {
-- log.warn('[OpenVibe.Video] Live did not answer', err && err.name);
-- throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
++ return unavailable(url, err && err.name);
}
if (res.status === 404) return null;
-- if (!res.ok) throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
++ if (!res.ok) return unavailable(url, `HTTP ${res.status}`);
let body;
try {
body = await res.json();
} catch {
-- throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
++ return unavailable(url, 'not JSON');
}
return remember(url, body);
}
ddiff --git a/test/video-live-stale.test.js b/test/video-live-stale.test.js
nnew file mode 100644
iindex 0000000..7e0a8c1
---- /dev/null
++++ b/test/video-live-stale.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * OpenVibe.Video asks OpenVibe.Live for its pages' recordings (server/video/live-client.js). Live waits on OpenVibe.Media,
++ * and Media stalls for seconds at times while it remuxes a live stream's DVR: production logged 23 "Live did not
++ * answer" in 12 hours on 2026-10-16. A page then shows Live's last answer (up to 30 minutes old) instead of an error;
++ * with nothing cached, or an answer older than that, it is still "OpenVibe.Live did not answer".
++ *
++ * node test/video-live-stale.test.js
++ */
++const assert = require('assert');
++const { createLiveClient, LiveUnavailableError } = require('../server/video/live-client');
++
++(async () => {
++ let now = Date.parse('2026-10-16T20:00:00Z');
++ const realNow = Date.now;
++ Date.now = () => now;
++ let mode = 'ok';
++ let calls = 0;
++ const fetchImpl = async () => {
++ calls++;
++ if (mode === 'timeout') { const e = new Error('The operation was aborted due to timeout'); e.name = 'TimeoutError'; throw e; }
++ if (mode === '502') return { ok: false, status: 502, json: async () => ({}) };
++ return { ok: true, status: 200, json: async () => ({ items: [{ id: calls }] }) };
++ };
++ const warnings = [];
++ const client = createLiveClient({ config: { video: { liveUrl: 'http://127.0.0.1:3000' } }, fetchImpl, log: { warn: (...a) => warnings.push(a.join(' ')) } });
++ try {
++ const first = await client.feed({ type: 'all', limit: 24 });
++ assert.deepStrictEqual(first.items, [{ id: 1 }]);
++ now += 10_000;
++ assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'a fresh answer comes from the cache');
++ assert.strictEqual(calls, 1);
++
++ now += 5 * 60_000;
++ mode = 'timeout';
++ assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'Live timing out: the last answer');
++ mode = '502';
++ assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'Live failing: the last answer');
++ assert.ok(warnings.every((w) => /serving its last answer/.test(w)), 'and the log says so');
++
++ mode = 'ok';
++ assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: calls }], 'Live back: a new answer');
++
++ now += 31 * 60_000;
++ mode = 'timeout';
++ await assert.rejects(client.feed({ type: 'all', limit: 24 }), LiveUnavailableError, 'an answer older than 30 minutes is not shown');
++ await assert.rejects(client.feed({ type: 'video', limit: 12 }), LiveUnavailableError, 'nothing cached: the error as before');
++ } finally {
++ Date.now = realNow;
++ }
++ console.log('video live stale: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });