* fix(jellycompat): guard aux search paths and index-back person search
Short, recursive type-ahead terms (e.g. a single "a") against the
PostgreSQL people index and the in-memory collection/box-set filter
produced multi-second scans that pegged the server when a client fired
one search per keystroke. Every jellycompat search path except the
Meilisearch-backed /Items media search now rejects a provided SearchTerm
shorter than 3 runes without touching any backend, and caps results at
20 regardless of the client-requested Limit:
- /Persons (PostgreSQL people scan)
- /Search/Hints (catalog search)
- /Items BoxSet (in-memory collection filter)
Shared policy + helpers live in search_guard.go (auxSearchMinTermLen=2,
i.e. reject 1-2 runes / allow 3+, and auxSearchMaxResults=20) with unit
coverage. The 3-rune floor is deliberate: 3 runes is the point where a
pg_trgm trigram index becomes usable, so the gate lines up with the index
and still lets legitimate short titles/names ("300", "Saw", 3-letter
actors) and 3-char type-ahead hints through.
PersonRepository.Search filters with `name ILIKE '%'||$1||'%'` rather
than `LOWER(name) LIKE '%'||LOWER($1)||'%'`. The old expression filtered
on LOWER(name), which the trigram GIN index idx_people_name_trgm (built
on name) could not serve, so every search fell back to an ordered index
scan on idx_people_name that walked the whole table for rare terms
(~300-400ms on the 889k-row production people table). ILIKE on name lets
pg_trgm serve rare 3+ char terms from the trigram index, while the planner
still picks the ordered btree scan with early termination for common
terms. ILIKE is case-insensitive, so behavior is preserved (including the
pre-existing treatment of % and _ in the term as LIKE wildcards).
Verified on production silo-postgres (889,302 people), parameterized query
under both custom and generic plan cache modes:
rare 3-char 'qzx': 304ms -> 1-8ms (trgm bitmap index)
rare 4-char 'zzzz': 329ms -> 1-2ms (trgm bitmap index)
common 3-char 'ann': 15ms -> 7-74ms (btree early-stop / trgm bitmap)
New worst case across all terms is ~83ms (generic-plan common 3-char).
* fix(jellycompat): stop gating meilisearch hints, clamp box-set search
Two review follow-ups on the aux-search guards:
- HandleSearchHints is served by the catalog (Meilisearch-backed) search
provider, which already bounds and short-term-handles its own results.
Gating it with auxSearchTermTooShort contradicted the guard's own
"non-Meilisearch paths only" policy and hid valid 1-2 char titles
("Up", "It") from global type-ahead. Drop the too-short gate there;
keep the empty-term check and the result clamp.
- handleBoxSetsList gated short terms but passed query.limit straight to
slicePage, which treats <=0 as no cap, so a box-set *search* was
uncapped unlike Persons/Hints. Clamp the limit when a search term is
present; empty-term browse keeps its client paging window.
56 lines
2.4 KiB
Go
56 lines
2.4 KiB
Go
package jellycompat
|
|
|
|
import "strings"
|
|
|
|
// Search-path guard policy for every jellycompat search EXCEPT the
|
|
// Meilisearch-backed /Items media search.
|
|
//
|
|
// Short, recursive type-ahead terms (e.g. a single "a") against the PostgreSQL
|
|
// people index and the in-memory collection/box-set filter produced multi-second
|
|
// scans that pegged the server when a client fired one search per keystroke.
|
|
// Anything that is not offloaded to Meilisearch is therefore gated: a provided
|
|
// term must be longer than auxSearchMinTermLen to run at all, and a guarded path
|
|
// never returns more than auxSearchMaxResults rows.
|
|
//
|
|
// The minimum allowed term length is 3 runes (auxSearchMinTermLen=2 is the
|
|
// longest length still rejected, so 1-2 runes are rejected and 3+ are allowed).
|
|
// 3 runes is the point where a pg_trgm trigram index becomes usable: a 2-char
|
|
// pattern yields no complete trigram, so it cannot be served from the people
|
|
// name trigram index and degrades to a ~400ms full scan, whereas any 3+ char
|
|
// term is trigram-eligible and stays fast. Aligning the gate with the trigram
|
|
// floor lets legitimate short titles/names ("300", "Saw", 3-letter actors) and
|
|
// 3-char type-ahead hints through.
|
|
const (
|
|
// auxSearchMinTermLen is the maximum SearchTerm length (counted in runes,
|
|
// after trimming) that is rejected. A non-empty term of this length or
|
|
// shorter returns no results WITHOUT querying any backend; only terms
|
|
// strictly longer than this are allowed to run.
|
|
auxSearchMinTermLen = 2
|
|
// auxSearchMaxResults caps how many results a guarded search path may return,
|
|
// regardless of the client-requested Limit.
|
|
auxSearchMaxResults = 20
|
|
)
|
|
|
|
// auxSearchTermTooShort reports whether a non-empty SearchTerm is too short to
|
|
// run on a guarded (non-/Items) search path.
|
|
//
|
|
// An empty term is NOT a search — it is a browse/list request — so it is never
|
|
// reported as too short; callers keep their existing empty-term behavior (the
|
|
// result limit is still clamped separately via clampAuxSearchLimit).
|
|
func auxSearchTermTooShort(term string) bool {
|
|
trimmed := strings.TrimSpace(term)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
return len([]rune(trimmed)) <= auxSearchMinTermLen
|
|
}
|
|
|
|
// clampAuxSearchLimit caps a client-requested limit to auxSearchMaxResults for
|
|
// guarded search paths. A non-positive request collapses to the cap.
|
|
func clampAuxSearchLimit(requested int) int {
|
|
if requested <= 0 || requested > auxSearchMaxResults {
|
|
return auxSearchMaxResults
|
|
}
|
|
return requested
|
|
}
|