fts: guard against pathological short (:*) prefix terms - #155
Conversation
Drop a multi-term FTS query's trailing token when it is shorter than minPrefixLen (=minWordSize=3) instead of turning it into a ':*' prefix tsquery. A 1-char prefix like "c:*" matches a huge fraction of a tenant's rows and forces a GIN prefix scan that does not short-circuit against the other ANDed terms, causing multi-second query times and gRPC deadline timeouts for downstream consumers (ConductorOne inc-947). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The raw trailing token was guarded, but buildSearchQuery emits :* for both the raw and the stemmed trailing token, and stemming can shorten a >=3-char token below minPrefixLen (going->go, ads->ad, its->it). That reintroduced the same pathological short GIN prefix scan on the stemmed arm. When the stemmed trailing token is too short, emit the stemmed arm as a plain exact match over the full stemmed text instead of a :* prefix; the raw arm keeps its (guarded) prefix. Adds tests: raw-long/stem-short (going -> no go:*), 2-rune multibyte trailing (rune-not-byte length), and asserts the retained term survives on drop cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up items from code review (deferred, non-blocking)A multi-perspective review (bugs / test / perf) of this PR caught one blocking gap that's now fixed in The following are non-blocking follow-ups intentionally left out of this PR:
Context: this PR is the fast-follow for a ConductorOne prod incident (inc-947) where a 1-char 🤖 Generated with Claude Code |
|
Re: "search The key thing: a single-term (Caveat so I'm not overselling it: a single common short word — e.g. The pathology Why drop the short trailing token instead of exact-matching it: content is edge-gram indexed only at Net: no regression to single-char search (already exact/near-empty for a rare char), and this only changes multi-term queries whose trailing token is 1–2 chars. |
Problem
buildSearchQuery(inpgdb/v1/fts.go, exported viaFullTextSearchQuery) appends a:*prefix operator to the trailing token of a multi-term query for typeahead. When that trailing token is very short, this is pathological.Confirmed in production via
EXPLAIN ANALYZE:c:*) matches ~52% of a tenant's entitlements.&&-ANDed terms, forcing a ~64s GIN prefix scan.DEADLINE_EXCEEDED) — residual of ConductorOne inc-947.Fix
Before emitting the multi-term
:*expression, if the trimmed trailing term is shorter thanminPrefixLen(utf8.RuneCountInString(lastTerm) < minPrefixLen), drop it and recurse on the remaining terms.minPrefixLen = minWordSize(= 3). Rationale is tied to the existingminWordSize = 3: tokens shorter than this are not independently indexed on the tsvector side either, so a<3-char trailing token cannot benefit from a real prefix match anyway — it only broadens the scan.:*.Behavior delta
<3runes change. That trailing token is dropped; the remaining terms (including prefix matching on the new trailing term, if≥3) are searched.≥3chars) is byte-identical — same generated SQL.:*).Tests
Added
TestBuildSearchQueryShortPrefix(no DB required — inspects the generated literal/args):≥3-char trailing token still emitstoken:*;:*(drop to the remaining terms);:*(also proves recursion terminates);Full
pgdb/v1suite (DB-backed, viapgtest) passes;go build ./...,go vet ./pgdb/v1, andgofmtare clean.Rollout
Consumers pick this up via a version bump (go.mod tag bump + re-vendor) — no runtime config.
🤖 Generated with Claude Code