openvibers/openvibe.mediahub Commits
Video's Search reads: serve a recent answer at once and refresh it behind the page (#263)
Changes
@@@ -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 = consoleconst 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,/**nnew file mode 100644@@@ -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); });
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
*/
*/
const TIMEOUT_MS = 4000;
const TIMEOUT_MS = 4000;
const CACHE_TTL_MS = 5 * 60_000;
const CACHE_TTL_MS = 5 * 60_000;
+const SERVE_STALE_MS = 60 * 60_000;
const CACHE_MAX = 200;
const CACHE_MAX = 200;
const PAGE = 24;
const PAGE = 24;
@@ -42,11 +43,22 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
@@ -42,11 +43,22 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
const base = String((config.search && config.search.url) || '').replace(/\/+$/, '');
const base = String((config.search && config.search.url) || '').replace(/\/+$/, '');
const cache = new Map(); // url → { at, value }
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) {
async function getJson(qs) {
if (!base) return null;
if (!base) return null;
const url = `${base}/api/v1/search?${qs.toString()}`;
const url = `${base}/api/v1/search?${qs.toString()}`;
const entry = cache.get(url);
const entry = cache.get(url);
if (entry && Date.now() - entry.at < CACHE_TTL_MS) return entry.value;
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 {
try {
const res = await fetchImpl(url, {
const res = await fetchImpl(url, {
method: 'GET', headers: { Accept: 'application/json' }, credentials: 'omit', cache: 'no-store',
method: 'GET', headers: { Accept: 'application/json' }, credentials: 'omit', cache: 'no-store',
@@ -82,6 +94,8 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
@@ -82,6 +94,8 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
}));
}));
return {
return {
+ /** Every background refresh in flight has finished (tests). */
+ settled: () => Promise.all([...refreshing.values()]),
enabled: Boolean(base),
enabled: Boolean(base),
KINDS,
KINDS,
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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); });