Rebuild: stats from a fixed-size sample, refreshed every 5 minutes, given up after 2
Test and deploy / test (push) Successful in 23s
Test and deploy / deploy (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-09 17:49:45 +02:00
1 parent f642a49d7d
commit 99fffe30f4
2 files changed
+23 -5

No files matched your search

+11 -5
View File
@@ -61,7 +61,9 @@ const SORTS: Record<SortOrder, string> = {
/** 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<never>((_, 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`
@@ -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`)
}
}