openvibers/openvibe.mediahub Commits
Video's Live reads: serve a recent answer at once and refresh it behind the page (#262)
Changes
@@@ -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 = consolereturn value;}++ const refreshing = new Map(); // url → the load that refreshes itasync 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) {@@@ -34,12 +34,22 @@ const { createLiveClient, LiveUnavailableError } = require('../server/video/livenow += 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';
@@ -16,6 +16,10 @@ const CACHE_TTL_MS = 60_000;
@@ -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
// (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".
// this old, instead of "OpenVibe.Live did not answer".
const STALE_MAX_MS = 30 * 60_000;
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 CACHE_MAX = 500;
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
@@ -80,11 +84,20 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -80,11 +84,20 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
return value;
return value;
}
}
+ const refreshing = new Map(); // url → the load that refreshes it
async function getJson(path) {
async function getJson(path) {
const url = `${config.video.liveUrl}${path}`;
const url = `${config.video.liveUrl}${path}`;
const cached = hit(url);
const cached = hit(url);
if (cached !== undefined) return cached;
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;
let res;
try {
try {
res = await fetchImpl(url, {
res = await fetchImpl(url, {
@@ -157,6 +170,8 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -157,6 +170,8 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
}
}
return {
return {
+ /** Every background refresh in flight has finished (tests). */
+ settled: () => Promise.all([...refreshing.values()]),
forgetItem,
forgetItem,
/** The person's own view of one item (a private one they own or may moderate included); null when Live says 404. */
/** 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) {
async vodAs(token, id) {
@@ -34,12 +34,22 @@ const { createLiveClient, LiveUnavailableError } = require('../server/video/live
@@ -34,12 +34,22 @@ const { createLiveClient, LiveUnavailableError } = require('../server/video/live
now += 5 * 60_000;
now += 5 * 60_000;
mode = 'timeout';
mode = 'timeout';
assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'Live timing out: the last answer');
assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'Live timing out: the last answer');
+ await client.settled();
mode = '502';
mode = '502';
assert.deepStrictEqual((await client.feed({ type: 'all', limit: 24 })).items, [{ id: 1 }], 'Live failing: the last answer');
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');
assert.ok(warnings.every((w) => /serving its last answer/.test(w)), 'and the log says so');
mode = 'ok';
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');
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;
now += 31 * 60_000;
mode = 'timeout';
mode = 'timeout';