Conversation
|
Ideally we strive for a no-manual-intervention deploy which this index makes not possible. If someone does not read the instructions then it is possible that they lock their deployment. Worst case we put it under a new major version but ideally we find a way to make it more fluid for self-hosters. |
|
Thanks for your quick reply. I agree with your framing and your concerns. Having some "migration notes" that you need to do or remember to do in advance is not a good idea. Migrations should be automatic. Luckily, this migration is indeed automatic. There's no manual intervention needed at all. There's nothing to be executed manually in advance; everything is prepared already to run this migration automatically without any prior manual work. The reason there's an explicit mention to run these queries CONCURRENTLY in the PR is because on big databases it might take a while, and I thought, it might be a handy recommendation for really BIG databases. The limitation of not being able to add CONCURRENTLY to the migration itself, is because of Prisma running things in a single transaction. As you can read in the PR, basically reads/SELECTs can still work while the automatic migration is applied, however, writes/updates/deletes will get locked during the duration of the automated migration. That's also a reason why I added this caveat. On small databases, locking writes/updates/deletes for few seconds should be totally fine. I was more thinking on a site that has a database with more contacts (e.g like maybe useplunk.com), not sure on how many contacts you might have, but if the database is big, like really big, having a lock on writes when running this migration concurrently might disrupt your clients, so it might be better to run in advance. I just ran some benchmarks for 1M, 5M and 10M contacts, on how much time would the writes be locked on my personal computer (AMD Ryzen AI 9 365, 20 cores, 64GB RAM) . Even though computers might vary in performance, at least we can have a sense of magnitude. Migration duration (non-concurrent)Postgres 16, local NVMe, no concurrent load,
By looking at this experiment/benchmark, it looks like the migration time grows linearly. 1 min migration for 10M rows does not seem excessive, but 100 million contacts would then take 10min of blocked writes on contacts table; maybe I was being extremely cautious when pointing out the CONCURRENT option, nonetheless again, no sure if you in your production server, or other people, will have 1M, 10M or 100M rows or more, thus, I'd rather edge on the side of caution and point it out. Of course, we should consider this ran this benchmark on my personal computer, times will vary in different computers depending on available memory and CPU. Some servers will definitely be faster, some will definitely be slower. Hopefully this clarifies this PR and unlocks it. Happy to hear your thoughts. |
…tact search Contact search ran email ILIKE '%term%', which no index can serve, so every request scanned every contact in the project. At 2M contacts that is ~430ms for the first page plus ~830ms for the uncapped total, per debounced keystroke. Every application write path already normalized via ContactService.normalizeEmail and 20260615120000_normalize_contact_emails repaired existing rows, but nothing enforced it -- so search had to keep using ILIKE to defend against a raw write reintroducing mixed case. A BEFORE INSERT OR UPDATE OF email trigger makes the invariant unconditional, which lets search drop to plain LIKE. The trigger's WHEN clause is evaluated in C, so already-normalized input never enters PL/pgSQL, and UPDATE OF email keeps it clear of the far more frequent subscribed/snoozedUntil writes. Adds two indexes, which cover disjoint cases and are both needed: (projectId, createdAt DESC, id DESC) ordered walk for the list and common terms GIN (email gin_trgm_ops) selective and zero-match search terms Shipping only the btree regresses a zero-match search to 585ms, worse than before, because the planner walks the whole project looking for rows that do not exist. The backfill fails loudly rather than silently merging case-variant duplicates: a correct merge has to reassign emails, events, workflow executions and segment memberships, and should not happen unreviewed at deploy time. CREATE INDEX uses IF NOT EXISTS so production can build both CONCURRENTLY ahead of the deploy and have these no-op; see the README next to the migration.
Emails are now lowercase at the database level, so mode: 'insensitive' buys nothing against a column that cannot contain uppercase -- and it costs roughly 3x on the terms too common for the trigram index to help with, while keeping equals off the (projectId, email) btree entirely. Folds the operand instead, via a named normalizeEmailSearch so the coupling to the database invariant is documented rather than implicit: ContactService.list contact list search bulk-contact-processor.buildQueryWhere bulk actions over a query selector SegmentService.removeContacts email lookup, now an indexed IN SegmentService.buildStringFieldCondition segment email filters The SegmentService case-insensitivity test created two contacts differing only by case in one project, which the trigger plus the (projectId, email) unique constraint now correctly reject -- case variants are one person, as 20260615120000_normalize_contact_emails established. Rewritten to assert that a mixed-case operand still matches, with three new tests covering the trigger itself via raw prisma.contact.create, which bypasses the service layer.
Measures the contact list and search queries against a synthetic 2M-contact corpus, before and after the lowercase-email change. Run by hand via 'yarn workspace @plunk/db bench:contact-search'; deliberately not wired into CI, since it seeds millions of rows and takes minutes. Seeds against the real Prisma migrations and replays the migration file verbatim for the after phase, so the benchmark cannot drift from what ships. Measures six search terms spanning the selectivity range, because the answer depends entirely on selectivity -- a trigram index does nothing for terms matching half the table, and cannot help at all below three characters, so a benchmark that only tested rare terms would report a win production never sees. Insert throughput uses a discarded warm-up plus median of 3 with a VACUUM between batches: single-batch measurements of the same configuration ranged 27k-46k rows/sec, wider than the effect being measured.
The migration comment and runbook both claimed a plain CREATE INDEX holds ACCESS EXCLUSIVE and blocks reads. It does not. Verified against Postgres 16: the strongest locks the migration takes are SHARE (CREATE INDEX) and SHARE ROW EXCLUSIVE (CREATE TRIGGER), neither of which conflicts with ACCESS SHARE. SELECT runs normally throughout; INSERT, UPDATE and DELETE block until the transaction commits. Also states plainly that pre-building CONCURRENTLY is an availability optimization rather than a correctness requirement -- the end state is identical either way, and the migration is transactional so a timeout rolls back cleanly.
90a22c0 to
306d43e
Compare
|
@driaug I rebased this image on top of current next branch. Migration is automatic. Have a look and let me know if there is anything for me to do on this branch or you think is good to go. |
Description
Contact search ran
email ILIKE '%term%', which no index can serve, so every request scanned every contact in the project. At 2M contacts that is ~416 ms for the first page plus ~856 ms for the uncapped total — per debounced keystroke, across three UI surfaces (contacts page, command palette, segment contact picker), and the contacts page has no minimum search length.Every application write path already normalized via
ContactService.normalizeEmail, and20260615120000_normalize_contact_emailsrepaired existing rows — but nothing enforced it, so search had to keep usingILIKEto defend against a raw write reintroducing mixed case.This makes the invariant unconditional with a
BEFORE INSERT OR UPDATE OF emailtrigger, which lets search drop to plainLIKE, and adds the two indexes the list and search queries need.The trigger's
WHENclause is evaluated in C, so already-normalized input never enters PL/pgSQL, andUPDATE OF emailkeeps it clear of the far more frequentsubscribed/snoozedUntilwrites from the bounce, complaint and snooze paths.Both indexes are needed — they cover disjoint cases:
(projectId, createdAt DESC, id DESC)GIN (email gin_trgm_ops)Shipping only the btree regresses a zero-match search to 585 ms — worse than doing nothing — because with no trigram alternative the planner walks the ordered index across the whole project hunting for 21 rows that do not exist. Every prefix a user types before their term matches is a zero-match search. Shipping only the trigram leaves the unfiltered list untouched at ~189 ms, since that query has no
emailpredicate for a trigram index to apply to.The backfill fails loudly rather than silently merging case-variant duplicates: a correct merge has to reassign emails, events, workflow executions and segment memberships, and should not happen unreviewed at deploy time.
Before → after at 2M contacts
Postgres 16.15,
en_US.utf8,shared_buffers=2GB, warm cache, median of 5EXPLAIN ANALYZEruns. 1,999,962 contacts in the queried project (2.4M rows total). Synthetic corpus — the harness is included in this PR (yarn workspace @plunk/db bench:contact-search).gmail.comez(2 chars)nguyenmartinezelena.ponszzqxWrite side, 50k-row bulk insert, median of 3:
Pre-normalized and mixed-case differ by ~2%, confirming the
WHENshort-circuit works — essentially the whole write cost is index maintenance, dominated by the GIN index. Note this measures bulk insert, the worst case for GIN maintenance; trickle single-row inserts profile differently.fastupdateis on by default, andgin_pending_list_limitis the tuning lever if write throughput binds.The count is the one thing not fully fixed:
gmail.comstays around 300 ms because counting 900k matching rows means visiting 900k rows, and no index changes that. The drop from 856 ms isLIKEreplacingILIKE. Capping it (SELECT count(*) FROM (SELECT id FROM ... LIMIT 10000) t) measured 13.6 ms in spot checks, but that changes what the UI can display, so it is deliberately out of scope here.Deployment note — build the indexes CONCURRENTLY first
This is an availability optimization, not a correctness requirement.
migrate deployproduces exactly the same end state either way, and the migration is transactional, so a timeout part-way through rolls the whole thing back cleanly with no partial state. Fresh installs and development databases can ignore this section entirely.What it buys you on an existing large
contactstable is not blocking writes. Prisma runs a migration inside a single transaction andCREATE INDEX CONCURRENTLYcannot run there, so the plainCREATE INDEXtakesSHAREand theCREATE TRIGGERtakesSHARE ROW EXCLUSIVE, both held until commit:contactsSELECTACCESS SHAREINSERT/UPDATE/DELETEOn a multi-million-row
contactsthe index builds take long enough to stall contact ingestion entirely — event tracking, imports and the API all write contacts. Blocked writes queue rather than fail, so without alock_timeoutthey accumulate and can exhaust the connection pool, turning a slow migration into a wider outage.CREATE INDEX IF NOT EXISTSis used specifically so you can avoid that: build both indexes concurrently ahead of the deploy and those statements become no-ops.1. Against the primary, over the direct connection (not PgBouncer in transaction mode —
CONCURRENTLYcannot run through it):2. Verify neither landed invalid.
CONCURRENTLYcan fail partway and leave an index that costs writes while serving no reads:Any
indisvalid = false→DROP INDEX CONCURRENTLYand rebuild before continuing.3. Deploy normally.
migrate deployruns the migration; the index statements no-op, and the trigger, backfill check and statistics target apply in milliseconds.If the backfill's
RAISE EXCEPTIONfires, case-variant duplicates appeared after the 20260615 merge. The full runbook — including how to list them and the rollback order — is inpackages/db/prisma/migrations/20260918120000_contact_email_lowercase/README.md.Type of Change
feat:New feature (MINOR version bump)fix:Bug fix (PATCH version bump)feat!:Breaking change - new feature (MAJOR version bump)fix!:Breaking change - bug fix (MAJOR version bump)docs:Documentation update (no version bump)chore:Maintenance/dependencies (no version bump)refactor:Code refactoring (no version bump)test:Adding tests (no version bump)perf:Performance improvement (PATCH version bump)Testing
prisma.contact.create, which bypasses the service layer — lowercase on insert, lowercase on update, and collapse onto the unique constraint.SegmentService's case-insensitivity test created two contacts differing only by case in the same project, which the trigger plus the(projectId, email)unique constraint now correctly reject — case variants are one person, as20260615120000_normalize_contact_emailsestablished. Rewritten to assert that a mixed-case operand still matches a single contact.prisma migrate diff --from-migrations --to-schema-datamodelreports no difference detected, so the declared indexes match the migration andmigrate devwill not emit a spurious DROP/CREATE.packages/db/benchmarks/contact-search/, run by hand (not wired into CI — it seeds millions of rows and takes minutes).A note on the schema declaration: a composite
(projectId, email)GIN index viabtree_ginmeasured 10–20% better than the plain trigram index, buttext_opsis the default GIN opclass fortext, so Postgres omits it from the catalog while Prisma requires it spelled out asraw("text_ops")— leaving the schema permanently drifted. Not worth that margin.Checklist
Related Issues
Closes #488