From 99fffe30f4f4f689a38fcb20f14330b22e244b34 Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Fri, 9 Oct 2026 17:49:45 +0200 Subject: [PATCH] Rebuild: stats from a fixed-size sample, refreshed every 5 minutes, given up after 2 TABLESAMPLE SYSTEM (0.5) read 30 s of random pages on the HDD at 7M releases, growing with the catalog; a refresh that never finished kept the header's totals 3.5 hours old (it blocked every later refresh). Now TABLESAMPLE SYSTEM_ROWS (5000) (tsm_system_rows), every 5 minutes, and a refresh is given up after 2 minutes. Co-Authored-By: Claude Opus 5.5 --- app/services/search_service.ts | 16 +++++++++++----- .../migrations/1770000001600_tsm_system_rows.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 database/migrations/1770000001600_tsm_system_rows.ts diff --git a/app/services/search_service.ts b/app/services/search_service.ts index ef3e34a..3dc0c88 100644 --- a/app/services/search_service.ts +++ b/app/services/search_service.ts @@ -61,7 +61,9 @@ const SORTS: Record = { /** A broad search needn't count every match (each counted row is a heap read). */ const COUNT_LIMIT = 10_000 -const STATS_TTL_MS = 60 * 1000 +const STATS_TTL_MS = 5 * 60 * 1000 +/** A refresh that takes longer is given up (the next request tries again). */ +const STATS_TIMEOUT_MS = 2 * 60 * 1000 /** ILIKE pattern of one word (the words are split on dots, dashes, underscores and spaces). */ function contains(word: string): string { @@ -188,7 +190,10 @@ class SearchService { /** Totals for the web interface, computed at most once a minute (callers in between share them). */ async stats() { if (!this.statsCache || Date.now() - this.statsCache.at > STATS_TTL_MS) { - this.statsRefresh ??= this.computeStats() + this.statsRefresh ??= Promise.race([ + this.computeStats(), + new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), STATS_TIMEOUT_MS).unref()), + ]) .then((value) => { this.statsCache = { at: Date.now(), value } }) @@ -210,13 +215,14 @@ class SearchService { /** * Estimates (a catalog of 100M+ rows can't be counted on every visit): * the row count from Postgres's statistics, below a million rows exact; - * readable share, files and size from a sample; ranges counted exactly - * (a few hundred thousand rows). + * readable share, files and size from a sample of 5,000 rows; ranges + * counted exactly (a few hundred thousand rows). */ private async computeStats() { const { rows } = await db.rawQuery(`SELECT reltuples::bigint AS n FROM pg_class WHERE relname = 'releases'`) const estimate = Number(rows[0]?.n ?? 0) - const sample = estimate < 1_000_000 ? '' : 'TABLESAMPLE SYSTEM (0.5)' + // A fixed-size sample: a percentage of a big catalog is many random reads on the HDD. + const sample = estimate < 1_000_000 ? '' : 'TABLESAMPLE SYSTEM_ROWS (5000)' const { rows: s } = await db.rawQuery( `SELECT count(*) AS sampled, count(*) FILTER (WHERE readable) AS readable, coalesce(sum(files), 0) AS files, coalesce(sum(bytes), 0) AS bytes FROM releases ${sample} WHERE files > 0` diff --git a/database/migrations/1770000001600_tsm_system_rows.ts b/database/migrations/1770000001600_tsm_system_rows.ts new file mode 100644 index 0000000..c5fc7a8 --- /dev/null +++ b/database/migrations/1770000001600_tsm_system_rows.ts @@ -0,0 +1,12 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** TABLESAMPLE SYSTEM_ROWS: the stats' sample of the catalog at a fixed size (a percentage read 30 s of random pages on the HDD at 7M releases). */ +export default class extends BaseSchema { + async up() { + this.schema.raw(`CREATE EXTENSION IF NOT EXISTS tsm_system_rows`) + } + + async down() { + this.schema.raw(`DROP EXTENSION IF EXISTS tsm_system_rows`) + } +}