Skip to content

perf: serve the name suggestions' prefix search from indexes - #91

Merged
TITeee merged 1 commit into
mainfrom
perf/prefix-search-indexes
Oct 10, 2026
Merged

TITeee merged 1 commit into
mainfrom
perf/prefix-search-indexes

Conversation

@TITeee

@TITeee TITeee commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Problem

GET /vulnerabilities/suggest took two to five seconds per request on a development database with millions of rows (NVD 2.6M, OSV 3.1M, CNA 0.2M). A prefix match is LIKE 'x%', and under any collation but C (a Windows or glibc/ICU locale: the default of most databases) a B-tree index cannot serve it, so each request read a whole index. heretix-management calls it as the user types, so every pause cost seconds.

Changes

  • Four expression indexes, lower(col) text_pattern_ops, on NVDAffectedPackage (packageName, vendor), OSVAffectedPackage (packageName) and CnaAffectedProduct (product), in the hand-written migration 20261010150000_add_lowercase_prefix_indexes. About 30 MB each; the build took 12 s on the development database and holds a write lock on its table meanwhile.
  • 20261010150100_analyze_prefix_search_tables runs ANALYZE on the three tables. Until a table is analyzed the planner has no statistics for lower(name) and keeps walking an older index in full, so the new indexes did nothing (measured: 1,951 ms before ANALYZE, 5.6 ms after).
  • The suggestions match on lower(name), so case needs no variants of the pattern (three patterns instead of six: as typed, and with a space as _ or -). Each pattern is its own LIKE, joined by OR: a LIKE ANY(array) cannot use these indexes.
  • The vendor lookup collects the matching vendors first, cuts them to limit, then reads each one's products. Asking for the first limit (vendor, product) pairs in one go made Postgres walk the whole (vendor, packageName) index in vendor order, filtering as it went.
  • docs/api.md and docs/operations.md ("Indexes Prisma does not manage") describe it.

Why expression indexes written in SQL

The schema can express @@index([col(ops: raw("text_pattern_ops"))]) and Prisma generates the right CREATE INDEX. But when it compares the database with schema.prisma it does not recognize the operator class of an existing index and reports it as different, so every migrate dev would propose dropping and recreating all four (checked with migrate diff against a migrated database). It ignores an expression index like lower(col) text_pattern_ops (checked the same way: the database with the new migrations against schema.prisma gives an empty diff).

Measured

Development database, warm cache, per request, before this change (the new matching without the indexes) and after:

Input Before After
nginx 2,563 ms 11 ms
http_s 2,856 ms 10 ms
lodash 2,800 ms 5 ms
connect secure 4,795 ms 6 ms
br-6208 3,144 ms 3 ms
ivanti (a vendor) 2,983 ms 10 ms
Microsoft (a vendor with many products) 2,324 ms 100 ms
a (matches a large share of the names) 22 ms 121 ms

Before #87 the suggestions took about 1.2 s. The one input that got slower is a single common letter, which used to stop early on an older index and now collects every match first.

Upgrade

The first start after the upgrade builds the indexes (seconds per million rows; the tables are write-locked meanwhile, before any import runs) and analyzes the tables. Without the indexes the suggestions still work, only slowly.

Test plan

  • vitest (741), test:integration (98, including the eight for /suggest), tsc --noEmit, eslint
  • Both migrations applied to the development and the test database; migrate status is up to date and migrate diff against schema.prisma is empty
  • After deploy, check the build time of the indexes and GET /vulnerabilities/suggest?q=nginx latency on the real database

Every suggestion read a whole index of millions of rows: under any
collation but "C" a plain B-tree cannot serve LIKE 'x%'. On the
development database a suggestion took two to five seconds.

The prefix is now matched on lower(name), so case needs no variants of
the pattern, against lower(...) text_pattern_ops indexes on the NVD,
OSV and CNA names and the NVD vendor. The indexes are expression
indexes added by a hand-written migration: Prisma's schema would
describe a text_pattern_ops index to the database it compares with as
different, and report drift on every migrate dev, whereas it leaves an
expression index alone. A second migration analyzes the three tables,
because until it does the planner ignores the new indexes.

Each pattern is its own LIKE joined by OR (a LIKE ANY(array) cannot use
the indexes), and the vendor lookup collects the matching vendors first
and then reads their products, which turned a walk over the whole
(vendor, packageName) index into a few lookups.
@TITeee
TITeee merged commit 401038c into main Oct 10, 2026
4 checks passed
@TITeee
TITeee deleted the perf/prefix-search-indexes branch October 10, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant