openvibers/openvibe.work Commits

perf: keep search counts 30 s per filter (#220)

OP OpenVibers committed 5 days ago · 066dd132ce · 2 files, +84 −3

Changes

ddiff --git a/server/jobs/store.js b/server/jobs/store.js
iindex 52f8c1b..acaa8c5 100644
---- a/server/jobs/store.js
++++ b/server/jobs/store.js
@@@ -15,6 +15,34 @@ const jobTypes = require('./job-types');
const PAGE_SIZE = 30;
const MAX_PAGE = 100;
++// A search page runs three scans of work_listings (21k rows / 23 ms each on 2026-11-08): the exact match count,
++// the remote facet and the job-type facet. Readers repeat the same filters (a crawler walking the catalogue), so
++// the two answers are kept for 30 s in production. Every call counts elsewhere, so a test reads its own writes.
++const COUNTS_MS = Math.max(0, Number(process.env.WORK_COUNTS_CACHE_MS || (process.env.NODE_ENV === 'production' ? 30_000 : 0)) || 0);
++const keptCounts = new Map();
++/** One read per key per window, shared by callers at the same moment; a failed read is not kept. */
++function kept(key, read, keepMs = COUNTS_MS, now = Date.now) {
++ if (!(keepMs > 0)) return Promise.resolve().then(read);
++ const t = now();
++ const hit = keptCounts.get(key);
++ if (hit && (hit.pending || t - hit.at < keepMs)) return hit.pending || Promise.resolve(hit.value);
++ const pending = Promise.resolve().then(read);
++ keptCounts.set(key, { at: t, pending });
++ if (keptCounts.size > 500) {
++ for (const [k, v] of keptCounts) if (!v.pending) keptCounts.delete(k);
++ if (keptCounts.size > 500) keptCounts.clear();
++ }
++ return pending.then((value) => {
++ const hold = keptCounts.get(key);
++ if (hold && hold.pending === pending) keptCounts.set(key, { at: t, value });
++ return value;
++ }, (err) => {
++ const hold = keptCounts.get(key);
++ if (hold && hold.pending === pending) keptCounts.delete(key);
++ throw err;
++ });
++}
++
// ── ids ─────────────────────────────────────────────────────
// A listing's id is derived from (source, source_id), so the same listing keeps the same URL on this site however
// often the board is re-read, and two sources can never collide. 26 base32 characters, the shape of a ULID.
@@@ -131,7 +159,8 @@ async function search(s, filters, { limit = PAGE_SIZE, cursor = null } = {}) {
const where = ['expired = false', sql];
const scope = where.join(' AND ');
// How many listings the reader's filters actually match, counted exactly rather than inferred from the page.
-- const matches = Number(await s.db.value(`SELECT count(*) AS n FROM work_listings WHERE ${scope}`, args));
++ const key = `${f.q}|${f.remote}|${f.type}|${f.location}`;
++ const matches = Number(await kept(`matches|${key}`, () => s.db.value(`SELECT count(*) AS n FROM work_listings WHERE ${scope}`, args)));
const at = cursor && cursor.sort_at && cursor.id ? cursor : null;
if (at) {
args.push(at.sort_at, at.id);
@@@ -144,7 +173,8 @@ async function search(s, filters, { limit = PAGE_SIZE, cursor = null } = {}) {
return {
rows: pageRows,
next: rows.length > n && last ? { sort_at: last.sort_at, id: last.id } : null,
-- facets: { matches, ...(await facets(s, f)) },
++ // The facets exclude the type and remote filters, so their key is only the text and place in force.
++ facets: { matches, ...(await kept(`facets|${f.q}|${f.location}`, () => facets(s, f))) },
};
}
@@@ -408,5 +438,5 @@ function decodeCursor(v) {
module.exports = {
search, facets, getListing, disclosureRate, countsBySource, listRecent, listSalaried, homeSummary, recentIds, upsertListing, upsertListings, expireListing, liveCount,
beginFetch, finishFetch, sourceFetches, expire, listSaved, createSaved, getSaved, deleteSaved, newCount, touchSaved, toWire, toSavedWire, filtersOf,
-- idOf, encodeCursor, decodeCursor, PAGE_SIZE, MAX_PAGE, MAX_SAVED, LISTING_ID,
++ idOf, encodeCursor, decodeCursor, PAGE_SIZE, MAX_PAGE, MAX_SAVED, LISTING_ID, COUNTS_MS, kept,
};
ddiff --git a/test/search-counts-cache.test.js b/test/search-counts-cache.test.js
nnew file mode 100644
iindex 0000000..5142df0
---- /dev/null
++++ b/test/search-counts-cache.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * A search page counted its listings three times per request (the exact match count, the remote facet and the
++ * job-type facet); on 2026-11-08 those three scans were 256 s, 240 s and 233 s of database time in 22 h. The
++ * counts are now kept for COUNTS_MS in production (server/jobs/store.js kept), keyed by the filters. This
++ * proves a repeated search reads the counts once, a changed filter reads them again, and the keep expires.
++ */
++process.env.WORK_COUNTS_CACHE_MS = '60000';
++const assert = require('assert');
++const { search, kept } = require('../server/jobs/store');
++
++(async () => {
++ let t = 0;
++ try {
++ const calls = { value: 0, many: 0 };
++ const s = {
++ db: {
++ value: async () => { calls.value += 1; return 0; },
++ many: async () => { calls.many += 1; return []; },
++ },
++ };
++ await search(s, { q: 'nurse', location: 'berlin' });
++ assert.deepStrictEqual(calls, { value: 1, many: 3 }, 'one count, two facet reads and the page on the first request');
++ await search(s, { q: 'nurse', location: 'berlin' });
++ assert.deepStrictEqual(calls, { value: 1, many: 4 }, 'the same filters reuse both counts; only the rows are read again');
++ await search(s, { q: 'nurse', location: 'munich' });
++ assert.strictEqual(calls.value, 2, 'a changed filter counts again');
++ assert.strictEqual(calls.many, 7, 'and reads both facets and the page again');
++ } finally {
++ delete process.env.WORK_COUNTS_CACHE_MS;
++ }
++
++ // The helper itself: in-flight callers share one read, the window is the keep, and a failure is not kept.
++ let reads = 0;
++ let release;
++ const gate = new Promise((resolve) => { release = resolve; });
++ const read = async () => { reads += 1; await gate; return 7; };
++ const [a, b] = [kept('t|in-flight', read, 60_000, () => t), kept('t|in-flight', read, 60_000, () => t)];
++ release();
++ assert.deepStrictEqual(await Promise.all([a, b]), [7, 7]);
++ assert.strictEqual(reads, 1, 'callers at the same moment share one read');
++ assert.strictEqual(await kept('t|in-flight', async () => { reads += 1; return 8; }, 60_000, () => t), 7, 'within the window: kept');
++ t = 60_000;
++ assert.strictEqual(await kept('t|in-flight', async () => { reads += 1; return 8; }, 60_000, () => t), 8, 'past the window: read again');
++ assert.strictEqual(reads, 2);
++ let failed = 0;
++ await assert.rejects(() => kept('t|fail', async () => { failed += 1; throw new Error('boom'); }, 60_000, () => t));
++ await assert.rejects(() => kept('t|fail', async () => { failed += 1; throw new Error('boom'); }, 60_000, () => t));
++ assert.strictEqual(failed, 2, 'a failed read is not kept');
++ console.log('ok - search counts are kept per filter and window');
++})().catch((err) => { console.error(err); process.exit(1); });