openvibers/openvibe.deals Commits

The sitemaps answer at once: an expired build is served while the next is made (#218)

OP OpenVibers committed 10 days ago · f7aabab21b · 3 files, +49 −6

A cold build of every offer's view (about 25,000) takes more than a minute. The reader who found
the ten-minute build expired, a crawler mostly, waited for it: on 11-03 at 08:45 nginx gave up after
60 s and answered /sitemap.xml with the "Back in a moment" page. With a ttl, an expired build is now
served at once and the next one is made behind it; the first build of the process is made 30 s after
start (DEALS_SITEMAP_WARM_MS), so no reader waits for it either. No ttl (tests) is unchanged.

Changes

ddiff --git a/server/config.js b/server/config.js
iindex 2f6b583..484028a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -54,6 +54,8 @@ function load(env = process.env) {
// at most once in this window and shared (one build serves concurrent readers either way). Each crawler fetch
// of /sitemap.xml rebuilt both: 5,000-24,000 slow queries a time on 2026-10-23. 0 = no reuse.
sitemapTtlMs: int(env.DEALS_SITEMAP_TTL_MS, isProduction ? 10 * 60 * 1000 : 0),
++ // The sitemaps' first build, this long after start so no reader waits for it (server/http/discovery.js). 0 = off.
++ sitemapWarmMs: int(env.DEALS_SITEMAP_WARM_MS, isProduction ? 30_000 : 0),
// The front page as one shared build. Every home request otherwise re-reads every section (the hot list's
// 41,000 latest-snapshot lookups and ~40 cards' store/observation/source reads among them). Reused for this
// window, then one background rebuild runs while readers keep the last copy for up to three minutes.
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a8743d5..0c66d61 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -49,14 +49,25 @@ async function mapLimit(items, limit, fn) {
return out;
}
--/** One build at a time, shared by the readers that ask while it runs, reused for ttlMs (0: the next read rebuilds). */
++/**
++ * One build at a time, shared by the readers that ask while it runs, reused for ttlMs (0: the next read rebuilds).
++ * With a ttl, an expired build is still served at once while the next one is made behind it: a cold build of every
++ * offer's view takes more than a minute, so the reader who found it expired (a crawler, mostly) waited past nginx's
++ * timeout and got the "Back in a moment" page (11-03). Only the first build of the process is waited for.
++ */
function shared(build, ttlMs, now) {
let value = null; let at = 0; let running = null;
-- return async () => {
-- if (value && now() - at < ttlMs) return value;
++ const rebuild = () => {
if (!running) running = build().then((v) => { value = v; at = now(); return v; }).finally(() => { running = null; });
-- return await running;
++ return running;
};
++ const get = async () => {
++ if (value && now() - at < ttlMs) return value;
++ if (value && ttlMs > 0) { rebuild().catch(() => { /* the last good list stays; the next read tries again */ }); return value; }
++ return await rebuild();
++ };
++ get.warm = () => rebuild();
++ return get;
}
function createDiscoveryRoutes({ config, store, publication, listings }) {
@@@ -78,6 +89,13 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
const pv = await publication.productView(p);
return { loc: abs(publication.productPath(p)), lastmod: Math.max(p.updated_at, ...pv.offers.map((v) => Math.max(v.root.updated_at, v.latest ? v.latest.observed_at : 0))), decision: pv.decision };
}), ttl, store.now);
++ // The first build of the process, made before any reader asks (DEALS_SITEMAP_WARM_MS after start; production 30 s):
++ // after it, every read is served at once (shared above).
++ const warmMs = Number(config.sitemapWarmMs) || 0;
++ if (warmMs > 0) {
++ const t = setTimeout(() => { offerEntries.warm().then(() => productEntries.warm()).catch(() => { /* a reader builds it then */ }); }, warmMs);
++ if (t.unref) t.unref();
++ }
/** The lists a person browses (all indexable while they have something to show); lastmod = their newest deal. */
async function pageEntries() {
@@@ -192,4 +210,4 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
return router;
}
--module.exports = { createDiscoveryRoutes };
++module.exports = { createDiscoveryRoutes, shared };
ddiff --git a/test/sitemap-build.test.js b/test/sitemap-build.test.js
iindex f648ec0..b3b08f4 100644
---- a/test/sitemap-build.test.js
++++ b/test/sitemap-build.test.js
@@@ -74,10 +74,33 @@ const maps = (text) => [...text.matchAll(/<sitemap><loc>([^<]+)<\/loc>/g)].map((
await s.get('/sitemap-0.xml');
assert.strictEqual(seen.views, 20, 'reused inside ten minutes');
clock.t += 2 * 60 * 1000;
-- await s.get('/sitemap-0.xml');
++ const expired = await s.get('/sitemap-0.xml');
++ assert.ok(expired.text.includes('https://deals.test/d/deal-19'), 'the expired build is served');
++ await new Promise((r) => setTimeout(r, 100)); // the next one is built behind it
assert.strictEqual(seen.views, 40, 'rebuilt after');
s.close();
++ // Served while rebuilding (11-03): an expired list answers at once; only the first build is waited on.
++ {
++ const { shared } = require('../server/http/discovery');
++ const t = { now: 0 };
++ let builds = 0; let release = null;
++ const get = shared(async () => { builds++; if (builds === 2) await new Promise((r) => { release = r; }); return builds; }, 1000, () => t.now);
++ assert.strictEqual(await get(), 1, 'the first build is waited on');
++ t.now = 5000;
++ assert.strictEqual(await get(), 1, 'expired: the last list at once');
++ assert.strictEqual(await get(), 1, 'and again while the next one is still building');
++ assert.strictEqual(builds, 2, 'one rebuild serves both readers');
++ release();
++ await new Promise((r) => setTimeout(r, 0));
++ assert.strictEqual(await get(), 2, 'then the new list');
++ let n = 0;
++ const none = shared(async () => ++n, 0, () => 0);
++ assert.deepStrictEqual([await none(), await none()], [1, 2], 'no ttl: every read waits on its own build, as before');
++ const failing = shared(async () => { throw new Error('db down'); }, 1000, () => 0);
++ await assert.rejects(failing(), /db down/, 'a cold build that fails fails the reader');
++ }
++
// A catalog past 10,000 URLs: the index names every child and the children are the old single sitemap, split.
const offerCount = 20005;
const offers = makeOffers(offerCount);