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)
Changes
@@@ -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 = consolecache.set(key, entry); // touch: this entry is now the most recentreturn 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 = consolesignal: 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);}nnew file mode 100644@@@ -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); });
@@ -12,6 +12,10 @@
@@ -12,6 +12,10 @@
*/
*/
const TIMEOUT_MS = 5000;
const TIMEOUT_MS = 5000;
const CACHE_TTL_MS = 60_000;
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 CACHE_MAX = 500;
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
@@ -49,10 +53,26 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -49,10 +53,26 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
cache.set(key, entry); // touch: this entry is now the most recent
cache.set(key, entry); // touch: this entry is now the most recent
return entry.value;
return entry.value;
}
}
- cache.delete(key);
+ // Expired entries stay (until STALE_MAX_MS) as the answer for when Live does not answer.
return undefined;
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) {
function remember(key, value) {
cache.delete(key);
cache.delete(key);
cache.set(key, { at: Date.now(), value });
cache.set(key, { at: Date.now(), value });
@@ -75,18 +95,17 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -75,18 +95,17 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
signal: AbortSignal.timeout(TIMEOUT_MS),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
});
} catch (err) {
} 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.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;
let body;
try {
try {
body = await res.json();
body = await res.json();
} catch {
} catch {
- throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
+ return unavailable(url, 'not JSON');
}
}
return remember(url, body);
return remember(url, body);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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); });