openvibers/openvibe.deals Commits
The sitemaps answer at once: an expired build is served while the next is made (#218)
Changes
@@@ -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.@@@ -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 };@@@ -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 itassert.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);
@@ -54,6 +54,8 @@ function load(env = process.env) {
@@ -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
// 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.
// 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),
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
// 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
// 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.
// window, then one background rebuild runs while readers keep the last copy for up to three minutes.
@@ -49,14 +49,25 @@ async function mapLimit(items, limit, fn) {
@@ -49,14 +49,25 @@ async function mapLimit(items, limit, fn) {
return out;
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) {
function shared(build, ttlMs, now) {
let value = null; let at = 0; let running = null;
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; });
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 }) {
function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -78,6 +89,13 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -78,6 +89,13 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
const pv = await publication.productView(p);
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 };
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);
}), 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. */
/** The lists a person browses (all indexable while they have something to show); lastmod = their newest deal. */
async function pageEntries() {
async function pageEntries() {
@@ -192,4 +210,4 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -192,4 +210,4 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
return router;
return router;
}
}
-module.exports = { createDiscoveryRoutes };
+module.exports = { createDiscoveryRoutes, shared };
@@ -74,10 +74,33 @@ const maps = (text) => [...text.matchAll(/<sitemap><loc>([^<]+)<\/loc>/g)].map((
@@ -74,10 +74,33 @@ const maps = (text) => [...text.matchAll(/<sitemap><loc>([^<]+)<\/loc>/g)].map((
await s.get('/sitemap-0.xml');
await s.get('/sitemap-0.xml');
assert.strictEqual(seen.views, 20, 'reused inside ten minutes');
assert.strictEqual(seen.views, 20, 'reused inside ten minutes');
clock.t += 2 * 60 * 1000;
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');
assert.strictEqual(seen.views, 40, 'rebuilt after');
s.close();
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.
// A catalog past 10,000 URLs: the index names every child and the children are the old single sitemap, split.
const offerCount = 20005;
const offerCount = 20005;
const offers = makeOffers(offerCount);
const offers = makeOffers(offerCount);