openvibers/openvibe.trade Commits
Trade /filings answers from a minute cache and asks its queries together (#177)
Changes
@@@ -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 }),});});nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -28,10 +28,15 @@ const views = require('../render/views');
@@ -28,10 +28,15 @@ const views = require('../render/views');
const home = require('../render/home');
const home = require('../render/home');
const { SITE_SUMMARY } = require('../render/layout');
const { SITE_SUMMARY } = require('../render/layout');
const { define } = require('./routes');
const { define } = require('./routes');
+const { createTtlCache } = require('./ttl-cache');
function createPages(ctx) {
function createPages(ctx) {
const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync, observations } = ctx;
const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync, observations } = ctx;
const router = express.Router();
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 } });
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.
// What shipped on OpenVibe.Trade: the shared update log every OpenVibe site has.
@@ -113,18 +118,29 @@ function createPages(ctx) {
@@ -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 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 pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
const forms = form ? [form] : null;
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 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);
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 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';
const label = form ? (views.formLabel(form) ? `${views.formLabel(form)}s (${form})` : `${form} filings`) : 'SEC filings as they arrive';
common.page(req, res, {
common.page(req, res, {
title: pageNo === 1 ? label : `${label}, page ${pageNo}`, canonical: urls.abs(form ? '/filings' : path),
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),
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.',
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 }),
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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); });