openvibers/openvibe.mediahub Commits

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

OP OpenVibers committed 11 days ago · 37e951825a · 2 files, +71 −0

After #262 the home still took 0.25-0.96 s at the origin now and then: its shelves come from
OpenVibe.Search, cached five minutes, and the visitor after that waited for Search. An answer under
an hour old is now returned at once while one refresh per URL replaces it; older is fetched as
before, and a failed Search still leaves the last answer.

Changes

ddiff --git a/server/video/search-reads.js b/server/video/search-reads.js
iindex a479a91..9b77d38 100644
---- a/server/video/search-reads.js
++++ b/server/video/search-reads.js
@@@ -12,6 +12,7 @@
*/
const TIMEOUT_MS = 4000;
const CACHE_TTL_MS = 5 * 60_000;
++const SERVE_STALE_MS = 60 * 60_000;
const CACHE_MAX = 200;
const PAGE = 24;
@@@ -42,11 +43,22 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
const base = String((config.search && config.search.url) || '').replace(/\/+$/, '');
const cache = new Map(); // url → { at, value }
++ // An answer past its five minutes but under an hour is served at once and refreshed behind the page, one refresh per
++ // URL: the next visitor no longer waits for Search (Video's home at 0.25-0.96 s at the origin on a miss, 11-02).
++ const refreshing = new Map();
async function getJson(qs) {
if (!base) return null;
const url = `${base}/api/v1/search?${qs.toString()}`;
const entry = cache.get(url);
if (entry && Date.now() - entry.at < CACHE_TTL_MS) return entry.value;
++ if (entry && Date.now() - entry.at < SERVE_STALE_MS) {
++ if (!refreshing.has(url)) refreshing.set(url, load(url, entry).finally(() => refreshing.delete(url)));
++ return entry.value;
++ }
++ return load(url, entry);
++ }
++
++ async function load(url, entry) {
try {
const res = await fetchImpl(url, {
method: 'GET', headers: { Accept: 'application/json' }, credentials: 'omit', cache: 'no-store',
@@@ -82,6 +94,8 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
}));
return {
++ /** Every background refresh in flight has finished (tests). */
++ settled: () => Promise.all([...refreshing.values()]),
enabled: Boolean(base),
KINDS,
/**
ddiff --git a/test/video-search-reads-stale.test.js b/test/video-search-reads-stale.test.js
nnew file mode 100644
iindex 0000000..933f2ce
---- /dev/null
++++ b/test/video-search-reads-stale.test.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * OpenVibe.Video reads its shelves and searches from OpenVibe.Search (server/video/search-reads.js), cached five
++ * minutes. Past that, an answer under an hour old is served at once and refreshed behind the page, one refresh per URL,
++ * so a visitor no longer waits for Search; an older one is fetched while they wait, and when Search fails the last
++ * answer still beats an empty shelf.
++ *
++ * node test/video-search-reads-stale.test.js
++ */
++const assert = require('assert');
++const { createSearchReads } = require('../server/video/search-reads');
++
++(async () => {
++ let now = Date.parse('2026-11-02T14:00:00Z');
++ const realNow = Date.now;
++ Date.now = () => now;
++ let calls = 0;
++ let fail = false;
++ const fetchImpl = async () => {
++ calls++;
++ if (fail) return { ok: false, status: 503, json: async () => ({}) };
++ return { ok: true, status: 200, json: async () => ({ results: [], facets: {}, n: calls }) };
++ };
++ const reads = createSearchReads({ config: { search: { url: 'http://127.0.0.1:4710' } }, fetchImpl, log: { warn() {} } });
++ const ask = () => reads.search('robot', { kind: 'vod' });
++ try {
++ await ask();
++ assert.strictEqual(calls, 1);
++ now += 60_000;
++ await ask();
++ assert.strictEqual(calls, 1, 'fresh for five minutes');
++
++ now += 10 * 60_000;
++ const answers = await Promise.all([ask(), ask(), ask()]);
++ assert.ok(answers.every((a) => a && a.items), 'past five minutes three readers get the last answer at once…');
++ await reads.settled();
++ assert.strictEqual(calls, 2, '…and one refresh runs behind the page for all three');
++ await ask();
++ assert.strictEqual(calls, 2, 'the refreshed answer is fresh again');
++
++ now += 10 * 60_000;
++ fail = true;
++ await ask();
++ await reads.settled();
++ await ask();
++ assert.ok(calls >= 3, 'a failed refresh is tried again later');
++
++ now += 2 * 60 * 60_000;
++ fail = false;
++ const before = calls;
++ await ask();
++ assert.strictEqual(calls, before + 1, 'older than an hour: fetched while the visitor waits');
++ } finally {
++ Date.now = realNow;
++ }
++ console.log('video search reads stale: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });