openvibers/openvibe.deals Commits

Deals home: one shared page build per minute, so the front page answers in milliseconds (#182)

OP OpenVibers committed 12 days ago · 4afc0fa3db · 6 files, +213 −2

The front page built every section on every request: the hot list scanned
41,000 active offers and probed their latest hotness snapshot in a 5.7M-row
table (0.4-1.0 s in production), and ~40 cards added four reads each. A
local production-shaped database (12k offers, 360k snapshots) took 664 ms
to build the page.

Build it once per DEALS_HOME_TTL_MS (60 s in production) and share it. Past
the window one background rebuild runs while readers keep the last copy for
at most three minutes, so no reader waits on a rebuild and a stale page
never outlives a few minutes. The build carries no viewer; layout stays per
request, and the Markdown twin shares the same build.

The page's content and HTML are unchanged: repeated reads are byte-identical.

Changes

ddiff --git a/.env.example b/.env.example
iindex 6a352a5..241abd9 100644
---- a/.env.example
++++ b/.env.example
@@@ -19,6 +19,9 @@ TRUST_PROXY=2
# How long the sitemaps' and /llms-full.txt's offer and product lists are reused, in ms (production default 600000; 0
# rebuilds on every read, the default elsewhere). Concurrent reads share one build either way.
#DEALS_SITEMAP_TTL_MS=600000
++# How long the front page's build is reused, in ms (production default 60000; 0 rebuilds on every read, the default
++# elsewhere). Past the window one background rebuild runs and readers keep the last copy for up to three minutes.
++#DEALS_HOME_TTL_MS=60000
# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh deals: the runtime role through PgBouncer,
# the owner on a direct connection (migrations), and Valkey (shared limit counters).
ddiff --git a/server/config.js b/server/config.js
iindex 924ab96..8e95600 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -54,6 +54,10 @@ 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 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.
++ homeTtlMs: int(env.DEALS_HOME_TTL_MS, isProduction ? 60 * 1000 : 0),
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
// Per-actor limits (server/http/actor-limits.js, roadmap WS-R task 4): the API reads one caller may
// make per minute and per hour. Writes set tighter numbers there.
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1a38ac7..a181561 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -37,6 +37,7 @@ const views = require('../render/views');
const home = require('../render/home');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { ApiError, iso } = require('../domain/values');
++const { ttlCache } = require('./ttl-cache');
const PER_PAGE = 25;
@@@ -226,10 +227,20 @@ function createPages(ctx) {
};
}
++ // The front page's build is the most expensive read on the site: the hot list looks up the latest hotness
++ // snapshot for every active offer (41,000 index probes over production's 5.7M snapshots, 0.4-1.0 s) and the
++ // cards add ~40 store/observation/source reads. Build it once per config.homeTtlMs and share it; past the
++ // window one background rebuild runs and readers keep the last copy for at most three minutes.
++ const homeData = ttlCache(homePage, {
++ ttlMs: config.homeTtlMs,
++ maxStaleMs: 3 * 60 * 1000,
++ now: store.now,
++ });
++
router.get('/', wrap(async (req, res) => {
// The hot list's later pages moved to /hot with the front page.
if (req.query.page && pageNumber(req) > 1) return res.redirect(301, `/hot?page=${pageNumber(req)}`);
-- send(req, res, 200, await homePage(), { cacheable: true });
++ send(req, res, 200, await homeData(), { cacheable: true });
}));
const sendList = (kind) => wrap(async (req, res) => {
const page = await listPage(kind, pageNumber(req));
@@@ -662,7 +673,7 @@ function createPages(ctx) {
// The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.
const publicViews = {
-- home: homePage, list: listPage, offer: offerBySlug, product: productPage,
++ home: homeData, list: listPage, offer: offerBySlug, product: productPage,
store: storePage, sellers: sellersIndexPage, seller: sellerPage,
categories: categoriesIndexPage, category: categoryPage, updates: updatesPage,
};
ddiff --git a/server/http/ttl-cache.js b/server/http/ttl-cache.js
nnew file mode 100644
iindex 0000000..3d20c58
---- /dev/null
++++ b/server/http/ttl-cache.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * One value shared by every reader, built at most once at a time and reused for ttlMs.
++ *
++ * A reader inside the window gets the value without touching the database. A reader after it gets
++ * the value we already have while one background rebuild starts (stale-while-revalidate); only when
++ * the value is older than maxStaleMs does a reader wait for the build. So a slow build costs the
++ * first reader after a deploy and nobody else, and a failed rebuild never outlives maxStaleMs.
++ *
++ * ttlMs = 0 turns it off: every read builds, exactly as before.
++ */
++function ttlCache(build, { ttlMs = 0, maxStaleMs = 0, now = Date.now } = {}) {
++ const ttl = Math.max(0, Number(ttlMs) || 0);
++ const maxStale = Math.max(ttl, Number(maxStaleMs) || 0);
++ let value = null;
++ let at = 0;
++ let running = null;
++
++ function refresh() {
++ if (!running) {
++ running = Promise.resolve().then(build).then((v) => {
++ value = v;
++ at = now();
++ return v;
++ }).finally(() => { running = null; });
++ }
++ return running;
++ }
++
++ return async function read() {
++ if (!ttl) return await refresh();
++ if (value) {
++ const age = Math.max(0, now() - at);
++ if (age < ttl) return value;
++ if (age < maxStale) {
++ // Serve what we have; the next readers get the rebuild. A failed rebuild keeps the
++ // old value until maxStale, then the next reader waits for one and sees the error.
++ refresh().catch(() => {});
++ return value;
++ }
++ }
++ return await refresh();
++ };
++}
++
++module.exports = { ttlCache };
ddiff --git a/test/home-cache.test.js b/test/home-cache.test.js
nnew file mode 100644
iindex 0000000..d2956e0
---- /dev/null
++++ b/test/home-cache.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * The front page is one build shared by its readers (DEALS_HOME_TTL_MS): a read inside the window
++ * does not touch the database, a read after it gets the last copy while one background rebuild runs,
++ * and the copy is never older than three minutes. The build itself carries no viewer, so a signed-in
++ * reader still gets their own navbar from the same data.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
++
++(async () => {
++ const t = await boot({ env: { DEALS_HOME_TTL_MS: '60000' } });
++ const alice = t.network.addUser('alice');
++ await t.submit(alice, { url: 'https://a.example/cached-1', title: 'Cached deal one', price: '10', currency: 'USD' });
++ const warm = await t.get('/');
++ assert.strictEqual(warm.status, 200);
++ assert.ok(warm.text.includes('Cached deal one'));
++ assert.match(warm.headers.get('cache-control'), /public, max-age=60/);
++
++ await check('a home read inside the window reuses the build, byte for byte', async () => {
++ await t.submit(alice, { url: 'https://a.example/cached-2', title: 'Cached deal two', price: '12', currency: 'USD' });
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Cached deal one'));
++ assert.ok(!r.text.includes('Cached deal two'), 'the new deal is not in the reused build (the window has not passed)');
++ assert.strictEqual(r.text, warm.text, 'the same HTML');
++ });
++
++ await check('after the window a reader gets the last copy and one background rebuild picks up the new deal', async () => {
++ t.clock.advance(61_000);
++ const stale = await t.get('/');
++ assert.strictEqual(stale.status, 200);
++ assert.ok(stale.text.includes('Cached deal one'));
++ assert.ok(!stale.text.includes('Cached deal two'), 'served from the last build, not a blocking rebuild');
++ let fresh = null;
++ for (let i = 0; i < 100 && !fresh; i++) {
++ await sleep(20);
++ const r = await t.get('/');
++ if (r.text.includes('Cached deal two')) fresh = r;
++ }
++ assert.ok(fresh, 'the background rebuild published the new deal');
++ assert.ok(fresh.text.includes('Cached deal one'));
++ });
++
++ await check('the shared build is viewer-neutral: a signed-in reader still gets their own navbar', async () => {
++ const anon = await t.get('/');
++ const user = await t.get('/', { as: alice });
++ assert.strictEqual(user.status, 200);
++ assert.ok(user.text.includes('Sign out'), 'the account controls are rendered for the viewer, not cached');
++ assert.ok(!anon.text.includes('Sign out'));
++ assert.ok(user.text.includes('Cached deal two'), 'the same deals');
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/ttl-cache.test.js b/test/ttl-cache.test.js
nnew file mode 100644
iindex 0000000..aac457a
---- /dev/null
++++ b/test/ttl-cache.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * The small shared-build cache the front page uses (server/http/ttl-cache.js): one build per window,
++ * one at a time, stale-while-revalidate up to maxStaleMs, and no reuse when ttlMs is 0.
++ */
++const assert = require('assert');
++const { check, done } = require('./helpers/boot');
++const { ttlCache } = require('../server/http/ttl-cache');
++
++const settle = async () => { await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); };
++const gate = () => { const g = {}; g.promise = new Promise((resolve) => { g.open = resolve; }); return g; };
++
++(async () => {
++ await check('inside ttlMs every reader shares one build', async () => {
++ let now = 1000;
++ let builds = 0;
++ const read = ttlCache(async () => ++builds, { ttlMs: 100, now: () => now });
++ assert.strictEqual(await read(), 1);
++ now += 99;
++ assert.strictEqual(await read(), 1);
++ assert.strictEqual(builds, 1);
++ });
++
++ await check('concurrent cold readers share one build', async () => {
++ const g = gate();
++ let builds = 0;
++ const read = ttlCache(async () => { builds++; await g.promise; return builds; }, { ttlMs: 100, now: () => 0 });
++ const [a, b] = [read(), read()];
++ await settle();
++ assert.strictEqual(builds, 1);
++ g.open();
++ assert.deepStrictEqual(await Promise.all([a, b]), [1, 1]);
++ });
++
++ await check('after ttlMs a reader gets the last value and one background build runs', async () => {
++ let now = 0;
++ let builds = 0;
++ const g = gate();
++ const read = ttlCache(async () => { builds++; if (builds > 1) await g.promise; return builds; }, { ttlMs: 100, maxStaleMs: 300, now: () => now });
++ assert.strictEqual(await read(), 1);
++ now = 101;
++ assert.strictEqual(await read(), 1, 'the last copy, immediately');
++ assert.strictEqual(await read(), 1, 'still the last copy while the rebuild is running');
++ await settle();
++ assert.strictEqual(builds, 2, 'one rebuild, not one per reader');
++ g.open();
++ await settle();
++ assert.strictEqual(await read(), 2, 'the rebuild is published');
++ });
++
++ await check('past maxStaleMs a reader waits for the build', async () => {
++ let now = 0;
++ let builds = 0;
++ const g = gate();
++ const read = ttlCache(async () => { builds++; if (builds > 1) await g.promise; return builds; }, { ttlMs: 100, maxStaleMs: 300, now: () => now });
++ assert.strictEqual(await read(), 1);
++ now = 301;
++ const next = read();
++ await settle();
++ assert.strictEqual(builds, 2, 'the build is already running');
++ g.open();
++ assert.strictEqual(await next, 2);
++ });
++
++ await check('a failed background build keeps the last copy until maxStaleMs, then surfaces the error', async () => {
++ let now = 0;
++ let builds = 0;
++ let fail = false;
++ const read = ttlCache(async () => { builds++; if (fail) throw new Error('database down'); return builds; }, { ttlMs: 100, maxStaleMs: 300, now: () => now });
++ assert.strictEqual(await read(), 1);
++ now = 101;
++ fail = true;
++ assert.strictEqual(await read(), 1, 'the last copy');
++ await settle();
++ assert.strictEqual(builds, 2, 'one rebuild was tried');
++ assert.strictEqual(await read(), 1, 'still the last copy inside maxStaleMs');
++ now = 301;
++ await assert.rejects(() => read(), /database down/, 'past maxStaleMs the reader sees the failure');
++ });
++
++ await check('ttlMs 0 builds on every read', async () => {
++ let builds = 0;
++ const read = ttlCache(async () => ++builds, { ttlMs: 0, now: () => 0 });
++ assert.deepStrictEqual([await read(), await read(), await read()], [1, 2, 3]);
++ });
++
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });