openvibers/openvibe.trade Commits

Trade /filings answers from a minute cache and asks its queries together (#177)

OP OpenVibers committed 12 days ago · 6a44b15136 · 3 files, +87 −4

Changes

ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 62ede78..1023e70 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -28,10 +28,15 @@ const views = require('../render/views');
const home = require('../render/home');
const { SITE_SUMMARY } = require('../render/layout');
const { define } = require('./routes');
++const { createTtlCache } = require('./ttl-cache');
function createPages(ctx) {
const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync, observations } = ctx;
const router = express.Router();
++ const FILINGS_PER_PAGE = 50;
++ // /filings is the same for every anonymous reader: its four queries (two counts, the page and the form list) were
++ // ~15 ms each on 9k documents. A minute is as stale as its own Cache-Control header already lets a shared cache be.
++ const filingsCache = createTtlCache({ ttlMs: 60_000, max: 100 });
const listing = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs(path), wordCount: 0 }, { policy: { minWords: 0 } });
// What shipped on OpenVibe.Trade: the shared update log every OpenVibe site has.
@@@ -113,18 +118,29 @@ function createPages(ctx) {
const form = typeof req.query.form === 'string' && /^[A-Za-z0-9 ./-]{1,20}$/.test(req.query.form) ? req.query.form.toUpperCase() : null;
const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
const forms = form ? [form] : null;
-- const total = await documents.countOf(forms);
++ const anonymous = !req.viewer || req.viewer.kind === 'anonymous';
++ const key = `${form || '*'}:${pageNo}`;
++ let cached = anonymous ? filingsCache.get(key) : undefined;
++ if (!cached) {
++ const [total, docs, counts] = await Promise.all([
++ documents.countOf(forms),
++ documents.recentOf(forms, { limit: FILINGS_PER_PAGE, offset: (pageNo - 1) * FILINGS_PER_PAGE }).then(withInstruments),
++ documents.formCounts(ctx.store.now() - 7 * DAY),
++ ]);
++ cached = { total, docs, counts };
++ if (anonymous) filingsCache.set(key, cached);
++ }
++ const { total, docs, counts } = cached;
const base = form ? `/filings?form=${encodeURIComponent(form)}` : '/filings';
-- const pager = paginate({ page: pageNo, perPage: 50, total, href: (p) => (p === 1 ? base : `${base}${form ? '&' : '?'}page=${p}`) });
++ const pager = paginate({ page: pageNo, perPage: FILINGS_PER_PAGE, total, href: (p) => (p === 1 ? base : `${base}${form ? '&' : '?'}page=${p}`) });
if (pager.outOfRange && total) return common.notFound(req, res);
-- const docs = await withInstruments(await documents.recentOf(forms, { limit: pager.limit, offset: pager.offset }));
const path = pageNo === 1 ? base : `${base}${form ? '&' : '?'}page=${pageNo}`;
const label = form ? (views.formLabel(form) ? `${views.formLabel(form)}s (${form})` : `${form} filings`) : 'SEC filings as they arrive';
common.page(req, res, {
title: pageNo === 1 ? label : `${label}, page ${pageNo}`, canonical: urls.abs(form ? '/filings' : path),
decision: form ? seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs('/filings'), wordCount: 0, noindex: true }, { policy: { minWords: 0 } }) : listing(path),
description: 'The newest SEC filings from EDGAR: annual and quarterly reports, current reports, proxies and prospectuses, each with when it was published and retrieved.',
-- feeds: FEEDS, body: views.filingsPage({ docs, pager, form, counts: await documents.formCounts(ctx.store.now() - 7 * DAY), urls }),
++ feeds: FEEDS, body: views.filingsPage({ docs, pager, form, counts, urls }),
});
});
ddiff --git a/server/http/ttl-cache.js b/server/http/ttl-cache.js
nnew file mode 100644
iindex 0000000..c61e740
---- /dev/null
++++ b/server/http/ttl-cache.js
@@@ -0,0 +1,30 @@
++'use strict';
++
++/**
++ * A small bounded TTL cache for public page data. The clocks are injected so a test can age an entry without sleeping;
++ * `max` evicts the oldest entry first, so a scanner asking for many forms and pages cannot grow it without bound.
++ *
++ * Only data that is the same for every anonymous reader goes in: the /filings listing (form + page) is the first user.
++ */
++function createTtlCache({ ttlMs, max = 100, now = Date.now } = {}) {
++ if (!(ttlMs > 0)) throw new RangeError('ttlMs must be positive');
++ if (!(max > 0)) throw new RangeError('max must be positive');
++ const entries = new Map();
++ return {
++ get(key) {
++ const hit = entries.get(key);
++ if (!hit) return undefined;
++ if (now() - hit.at >= ttlMs) { entries.delete(key); return undefined; }
++ return hit.value;
++ },
++ set(key, value) {
++ entries.delete(key); // re-insert so the most recently written is the last to be evicted
++ entries.set(key, { at: now(), value });
++ while (entries.size > max) entries.delete(entries.keys().next().value);
++ },
++ clear() { entries.clear(); },
++ get size() { return entries.size; },
++ };
++}
++
++module.exports = { createTtlCache };
ddiff --git a/test/ttl-cache.test.js b/test/ttl-cache.test.js
nnew file mode 100644
iindex 0000000..25bda06
---- /dev/null
++++ b/test/ttl-cache.test.js
@@@ -0,0 +1,37 @@
++'use strict';
++/**
++ * The bounded TTL cache behind /filings (server/http/ttl-cache.js): an entry lives for the TTL, a rewrite refreshes it,
++ * and the cache never keeps more than `max` entries however many forms or pages a scanner asks for.
++ */
++const assert = require('assert');
++const { createTtlCache } = require('../server/http/ttl-cache');
++
++(async () => {
++ let now = 1_000;
++ const cache = createTtlCache({ ttlMs: 100, max: 2, now: () => now });
++
++ assert.strictEqual(cache.get('missing'), undefined, 'nothing cached yet');
++ cache.set('a', { n: 1 });
++ assert.deepStrictEqual(cache.get('a'), { n: 1 }, 'a fresh entry is served');
++ now = 1_099;
++ assert.deepStrictEqual(cache.get('a'), { n: 1 }, 'just inside the TTL');
++ now = 1_100;
++ assert.strictEqual(cache.get('a'), undefined, 'expired at the TTL, not after it');
++
++ cache.set('a', 1);
++ cache.set('b', 2);
++ cache.set('c', 3);
++ assert.strictEqual(cache.size, 2, 'bounded by max');
++ assert.strictEqual(cache.get('a'), undefined, 'the oldest entry went first');
++ assert.strictEqual(cache.get('c'), 3);
++
++ now = 1_150;
++ cache.set('b', 4);
++ assert.strictEqual(cache.get('b'), 4, 'a rewrite refreshes the value and its clock');
++ now = 1_249;
++ assert.strictEqual(cache.get('b'), 4, 'the rewrite restarted the TTL');
++
++ cache.clear();
++ assert.strictEqual(cache.size, 0, 'clear empties it');
++ console.log('all passed');
++})().catch((err) => { console.error(err); process.exit(1); });