openvibers/openvibe.deals Commits
Deals home: one shared page build per minute, so the front page answers in milliseconds (#182)
Changes
@@@ -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).@@@ -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.@@@ -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,};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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; });nnew file mode 100644@@@ -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; });
@@ -19,6 +19,9 @@ TRUST_PROXY=2
@@ -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
# 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.
# rebuilds on every read, the default elsewhere). Concurrent reads share one build either way.
#DEALS_SITEMAP_TTL_MS=600000
#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,
# 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).
# the owner on a direct connection (migrations), and Valkey (shared limit counters).
@@ -54,6 +54,10 @@ function load(env = process.env) {
@@ -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
// 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 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,
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
// 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.
// make per minute and per hour. Writes set tighter numbers there.
@@ -37,6 +37,7 @@ const views = require('../render/views');
@@ -37,6 +37,7 @@ const views = require('../render/views');
const home = require('../render/home');
const home = require('../render/home');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { ApiError, iso } = require('../domain/values');
const { ApiError, iso } = require('../domain/values');
+const { ttlCache } = require('./ttl-cache');
const PER_PAGE = 25;
const PER_PAGE = 25;
@@ -226,10 +227,20 @@ function createPages(ctx) {
@@ -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) => {
router.get('/', wrap(async (req, res) => {
// The hot list's later pages moved to /hot with the front page.
// 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)}`);
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 sendList = (kind) => wrap(async (req, res) => {
const page = await listPage(kind, pageNumber(req));
const page = await listPage(kind, pageNumber(req));
@@ -662,7 +673,7 @@ function createPages(ctx) {
@@ -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.
// The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.
const publicViews = {
const publicViews = {
- home: homePage, list: listPage, offer: offerBySlug, product: productPage,
+ home: homeData, list: listPage, offer: offerBySlug, product: productPage,
store: storePage, sellers: sellersIndexPage, seller: sellerPage,
store: storePage, sellers: sellersIndexPage, seller: sellerPage,
categories: categoriesIndexPage, category: categoryPage, updates: updatesPage,
categories: categoriesIndexPage, category: categoryPage, updates: updatesPage,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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; });