openvibers/openvibe.work Commits
perf: keep search counts 30 s per filter (#220)
Changes
@@@ -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,};nnew file mode 100644@@@ -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); });
@@ -15,6 +15,34 @@ const jobTypes = require('./job-types');
@@ -15,6 +15,34 @@ const jobTypes = require('./job-types');
const PAGE_SIZE = 30;
const PAGE_SIZE = 30;
const MAX_PAGE = 100;
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 ─────────────────────────────────────────────────────
// ── ids ─────────────────────────────────────────────────────
// A listing's id is derived from (source, source_id), so the same listing keeps the same URL on this site however
// 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.
// 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 } = {}) {
@@ -131,7 +159,8 @@ async function search(s, filters, { limit = PAGE_SIZE, cursor = null } = {}) {
const where = ['expired = false', sql];
const where = ['expired = false', sql];
const scope = where.join(' AND ');
const scope = where.join(' AND ');
// How many listings the reader's filters actually match, counted exactly rather than inferred from the page.
// 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;
const at = cursor && cursor.sort_at && cursor.id ? cursor : null;
if (at) {
if (at) {
args.push(at.sort_at, at.id);
args.push(at.sort_at, at.id);
@@ -144,7 +173,8 @@ async function search(s, filters, { limit = PAGE_SIZE, cursor = null } = {}) {
@@ -144,7 +173,8 @@ async function search(s, filters, { limit = PAGE_SIZE, cursor = null } = {}) {
return {
return {
rows: pageRows,
rows: pageRows,
next: rows.length > n && last ? { sort_at: last.sort_at, id: last.id } : null,
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) {
@@ -408,5 +438,5 @@ function decodeCursor(v) {
module.exports = {
module.exports = {
search, facets, getListing, disclosureRate, countsBySource, listRecent, listSalaried, homeSummary, recentIds, upsertListing, upsertListings, expireListing, liveCount,
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,
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,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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); });