diff --git a/app/services/search_service.ts b/app/services/search_service.ts index 2df138b..598a005 100644 --- a/app/services/search_service.ts +++ b/app/services/search_service.ts @@ -61,8 +61,8 @@ export interface ReleaseSummary { innerName: string | null } -/** Posters a poster search looks in at most (a short fragment matches thousands of random ones). */ -const MAX_POSTERS = 1000 +/** Posters a poster search looks in at most: a short fragment matches thousands of random ones (1,000 took 9.9 s, 200 0.2 s; LOG.md 2026-10-16 (70)). */ +const MAX_POSTERS = 200 const SORTS: Record = { newest: 'r.posted_at DESC NULLS LAST, r.id DESC', oldest: 'r.posted_at ASC NULLS LAST, r.id ASC', @@ -103,12 +103,13 @@ class SearchService { where.push('r.source = ?') bindings.push(params.source) } + // A poster search: the matching posters first (posters, by trigram), each one's releases then by + // releases_poster_newest_idx (below). "poster ILIKE" on releases walked all 50M rows newest first: 30+ min for a + // poster with few releases; as a filter on the posters found the planner still walked them (LOG.md 2026-10-16 (70)). + let posters: string[] | null = null if (params.poster) { - // The matching posters first (posters, by trigram), then their releases by releases_poster_newest_idx: "poster ILIKE" - // on releases walked all 50M rows newest first, 30+ min for a poster with few releases (LOG.md 2026-10-16 (70)). - // By the first 200 characters, as posters and the index keep them (a btree entry holds at most 2,704 bytes). - where.push(`left(r.poster, 200) = ANY(ARRAY(SELECT p.poster FROM posters p WHERE p.poster ILIKE ? LIMIT ${MAX_POSTERS}))`) - bindings.push(contains(params.poster)) + const { rows: matched } = await db.rawQuery(`SELECT poster FROM posters WHERE poster ILIKE ? LIMIT ${MAX_POSTERS}`, [contains(params.poster)]) + posters = matched.map((m: { poster: string }) => m.poster) } if (params.categories?.length) { where.push('r.category = ANY(?)') @@ -139,14 +140,34 @@ class SearchService { const order = SORTS[params.sort ?? 'newest'] ?? SORTS.newest const whereSql = where.join(' AND ') const countLimit = offset + (params.countLimit ?? COUNT_LIMIT) + if (posters !== null && posters.length === 0) return { page, perPage, offset, total: 0, totalCapped: false, releases: [] as ReleaseSummary[] } + // Per poster found (by first 200 characters, as posters and the index keep them; a btree entry holds at most 2,704 + // bytes): its releases matching everything else, the first offset + perPage in the order asked; then merged. + const source = + posters === null + ? { from: 'releases r', where: whereSql, bindings: [] as unknown[] } + : { + from: `unnest(?::text[]) AS p(poster) CROSS JOIN LATERAL ( + SELECT r.* FROM releases r WHERE left(r.poster, 200) = p.poster AND ${whereSql} ORDER BY ${order} LIMIT ${offset + perPage}) r`, + where: 'true', + bindings: [posters] as unknown[], + } + const counting = + posters === null + ? db.rawQuery(`SELECT count(*) AS total FROM (SELECT 1 FROM releases r WHERE ${whereSql} LIMIT ${countLimit + 1}) x`, bindings) + : db.rawQuery( + `SELECT count(*) AS total FROM (SELECT 1 FROM unnest(?::text[]) AS p(poster) CROSS JOIN LATERAL ( + SELECT 1 FROM releases r WHERE left(r.poster, 200) = p.poster AND ${whereSql} LIMIT ${countLimit + 1}) x LIMIT ${countLimit + 1}) y`, + [posters, ...bindings] + ) const [{ rows }, { rows: counted }] = await Promise.all([ db.rawQuery( `SELECT r.id, r.name, r.source, r.poster, r.groups, r.posted_at, r.file_total, r.files, r.bytes, r.readable, r.complete, r.category, r.settled_at, r.passworded, r.inner_name, release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete) AS final - FROM releases r WHERE ${whereSql} ORDER BY ${order} LIMIT ? OFFSET ?`, - [...bindings, perPage, offset] + FROM ${source.from} WHERE ${source.where} ORDER BY ${order} LIMIT ? OFFSET ?`, + [...source.bindings, ...bindings, perPage, offset] ), - db.rawQuery(`SELECT count(*) AS total FROM (SELECT 1 FROM releases r WHERE ${whereSql} LIMIT ${countLimit + 1}) x`, bindings), + counting, ]) return { page, diff --git a/tests/functional/poster_search.spec.ts b/tests/functional/poster_search.spec.ts index 1a6f227..6d261b3 100644 --- a/tests/functional/poster_search.spec.ts +++ b/tests/functional/poster_search.spec.ts @@ -24,14 +24,21 @@ test.group('Poster search', (group) => { { 'subject': `[1/1] - "${name}" yEnc (1/1)`, 'from': from, 'date': 'Mon, 05 Oct 2026 10:00:00 +0000', 'message-id': ``, ':bytes': '1000', 'xref': `x ${GROUP}:${n}` }, ] const ix = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } }) - ix.add([post(1, 'Kitchen Cook ', 'Soup.Recipes.2026.rar'), post(2, 'Kitchen Cook ', 'Bread.Recipes.2026.rar'), post(3, 'Gardener ', 'Roses.2026.rar')], 4) + ix.add([post(1, 'Kitchen Cook ', 'Soup.Recipes.2026.rar'), post(2, 'Kitchen Cook ', 'Bread.Recipes.2026.rar'), post(3, 'Gardener ', 'Roses.2026.rar'), post(4, 'Kitchen Helper ', 'Cake.2026.rar')], 5) ix.finish() await BatchStore.store(Number(range.id), Number(node.id), ix.take()) - assert.sameMembers((await db.from('posters').select('poster')).map((p) => p.poster), ['Kitchen Cook ', 'Gardener ']) + assert.sameMembers((await db.from('posters').select('poster')).map((p) => p.poster), ['Kitchen Cook ', 'Gardener ', 'Kitchen Helper ']) const found = await SearchService.search({ poster: 'kitchen' }) - assert.equal(found.total, 2) - assert.isTrue(found.releases.every((r) => r.poster === 'Kitchen Cook ')) + assert.equal(found.total, 3) + assert.isTrue(found.releases.every((r) => r.poster?.startsWith('Kitchen'))) + // Pages over the posters found, merged in the order asked. + const second = await SearchService.search({ poster: 'kitchen', perPage: 2, page: 2 }) + assert.equal(second.total, 3) + assert.equal(second.releases.length, 1) + const all = [...(await SearchService.search({ poster: 'kitchen', perPage: 2, page: 1 })).releases, ...second.releases].map((r) => r.id) + assert.sameMembers(all, found.releases.map((r) => r.id)) + assert.equal((await SearchService.search({ poster: 'cook', q: 'Bread' })).total, 1) assert.equal((await SearchService.search({ poster: 'nobody' })).total, 0) }) })