Poster search per poster found (LATERAL on releases_poster_newest_idx), at most 200 posters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0e00f1707
commit
c55918350e
2 files changed
+42
-14
No files matched your search
@@ -61,8 +61,8 @@ export interface ReleaseSummary {
|
|||||||
innerName: string | null
|
innerName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Posters a poster search looks in at most (a short fragment matches thousands of random ones). */
|
/** 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 = 1000
|
const MAX_POSTERS = 200
|
||||||
const SORTS: Record<SortOrder, string> = {
|
const SORTS: Record<SortOrder, string> = {
|
||||||
newest: 'r.posted_at DESC NULLS LAST, r.id DESC',
|
newest: 'r.posted_at DESC NULLS LAST, r.id DESC',
|
||||||
oldest: 'r.posted_at ASC NULLS LAST, r.id ASC',
|
oldest: 'r.posted_at ASC NULLS LAST, r.id ASC',
|
||||||
@@ -103,12 +103,13 @@ class SearchService {
|
|||||||
where.push('r.source = ?')
|
where.push('r.source = ?')
|
||||||
bindings.push(params.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) {
|
if (params.poster) {
|
||||||
// The matching posters first (posters, by trigram), then their releases by releases_poster_newest_idx: "poster ILIKE"
|
const { rows: matched } = await db.rawQuery(`SELECT poster FROM posters WHERE poster ILIKE ? LIMIT ${MAX_POSTERS}`, [contains(params.poster)])
|
||||||
// on releases walked all 50M rows newest first, 30+ min for a poster with few releases (LOG.md 2026-10-16 (70)).
|
posters = matched.map((m: { poster: string }) => m.poster)
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
if (params.categories?.length) {
|
if (params.categories?.length) {
|
||||||
where.push('r.category = ANY(?)')
|
where.push('r.category = ANY(?)')
|
||||||
@@ -139,14 +140,34 @@ class SearchService {
|
|||||||
const order = SORTS[params.sort ?? 'newest'] ?? SORTS.newest
|
const order = SORTS[params.sort ?? 'newest'] ?? SORTS.newest
|
||||||
const whereSql = where.join(' AND ')
|
const whereSql = where.join(' AND ')
|
||||||
const countLimit = offset + (params.countLimit ?? COUNT_LIMIT)
|
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([
|
const [{ rows }, { rows: counted }] = await Promise.all([
|
||||||
db.rawQuery(
|
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,
|
`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
|
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 ?`,
|
FROM ${source.from} WHERE ${source.where} ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||||
[...bindings, perPage, 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 {
|
return {
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -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': `<ps${n}@x>`, ':bytes': '1000', 'xref': `x ${GROUP}:${n}` },
|
{ 'subject': `[1/1] - "${name}" yEnc (1/1)`, 'from': from, 'date': 'Mon, 05 Oct 2026 10:00:00 +0000', 'message-id': `<ps${n}@x>`, ':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 } } })
|
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 <cook@example.com>', 'Soup.Recipes.2026.rar'), post(2, 'Kitchen Cook <cook@example.com>', 'Bread.Recipes.2026.rar'), post(3, 'Gardener <garden@example.com>', 'Roses.2026.rar')], 4)
|
ix.add([post(1, 'Kitchen Cook <cook@example.com>', 'Soup.Recipes.2026.rar'), post(2, 'Kitchen Cook <cook@example.com>', 'Bread.Recipes.2026.rar'), post(3, 'Gardener <garden@example.com>', 'Roses.2026.rar'), post(4, 'Kitchen Helper <help@example.com>', 'Cake.2026.rar')], 5)
|
||||||
ix.finish()
|
ix.finish()
|
||||||
await BatchStore.store(Number(range.id), Number(node.id), ix.take())
|
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 <cook@example.com>', 'Gardener <garden@example.com>'])
|
assert.sameMembers((await db.from('posters').select('poster')).map((p) => p.poster), ['Kitchen Cook <cook@example.com>', 'Gardener <garden@example.com>', 'Kitchen Helper <help@example.com>'])
|
||||||
const found = await SearchService.search({ poster: 'kitchen' })
|
const found = await SearchService.search({ poster: 'kitchen' })
|
||||||
assert.equal(found.total, 2)
|
assert.equal(found.total, 3)
|
||||||
assert.isTrue(found.releases.every((r) => r.poster === 'Kitchen Cook <cook@example.com>'))
|
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)
|
assert.equal((await SearchService.search({ poster: 'nobody' })).total, 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user