Files
silo-server/internal/jellycompat/search_guard_test.go
CoffeeKnyteandGitHub 1dbcf2cd9a fix(jellycompat): guard aux search paths and index-back person search (#252)
* 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.
2026-07-01 09:05:41 -04:00

46 lines
1.4 KiB
Go

package jellycompat
import "testing"
func TestAuxSearchTermTooShort(t *testing.T) {
cases := []struct {
term string
want bool
}{
{"", false}, // empty is a browse, not a search
{" ", false}, // whitespace trims to empty -> browse
{"a", true}, // 1 char
{"ab", true}, // 2 chars -> too short (below the trigram floor)
{"abc", false}, // 3 chars -> allowed (trigram-eligible)
{" abc ", false}, // trims to 3 chars -> allowed
{"abcd", false}, // 4 chars -> allowed
{"america", false}, // long term -> allowed
{"日本", true}, // 2 runes -> too short (rune-counted, not bytes)
{"日本語", false}, // 3 runes -> allowed
}
for _, c := range cases {
if got := auxSearchTermTooShort(c.term); got != c.want {
t.Errorf("auxSearchTermTooShort(%q) = %v, want %v", c.term, got, c.want)
}
}
}
func TestClampAuxSearchLimit(t *testing.T) {
cases := []struct {
in int
want int
}{
{0, auxSearchMaxResults}, // unset collapses to cap
{-5, auxSearchMaxResults}, // negative collapses to cap
{5, 5}, // under cap preserved
{20, 20}, // exactly cap preserved
{21, auxSearchMaxResults}, // over cap clamped
{1000, auxSearchMaxResults}, // way over cap clamped
}
for _, c := range cases {
if got := clampAuxSearchLimit(c.in); got != c.want {
t.Errorf("clampAuxSearchLimit(%d) = %d, want %d", c.in, got, c.want)
}
}
}