perf: add pg_trgm GIN indexes for backoffice organization search - #14325
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
OpenAPI ChangesNo changes detected in the OpenAPI schema. |
There was a problem hiding this comment.
2 issues found across 3 files
Confidence score: 3/5
server/migrations/versions/2026-09-09-2145_add_pg_trgm_gin_indexes_for_backoffice_.pydoes not make one- or two-character organization searches index-backed, so short queries may remain slow; enforce a minimum search length before running the query.server/polar/models/organization.pyleavesOrganization.slugsearches unable to use the trigram index becauseCITEXTselects a different operator; create the index with the compatible text/operator expression and verify the generated query plan.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="server/migrations/versions/2026-09-09-2145_add_pg_trgm_gin_indexes_for_backoffice_.py">
<violation number="1" location="server/migrations/versions/2026-09-09-2145_add_pg_trgm_gin_indexes_for_backoffice_.py:55">
P2: When `q` is one or two characters, these indexes do not make the organization search index-backed because `pg_trgm` cannot extract a trigram from the pattern. Enforce a minimum search length before running this query or add a separate strategy for short searches.</violation>
</file>
<file name="server/polar/models/organization.py">
<violation number="1" location="server/polar/models/organization.py:542">
P2: Slug searches still cannot use this trigram index because `Organization.slug` is `CITEXT`, so `ilike()` resolves to citext's operator rather than the `text` operator in `gin_trgm_ops`. Create the trigram index on `slug::text` and cast the slug expression to `text` in the backoffice predicate (updating both the migration and ORM declaration).</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| [column], | ||
| postgresql_concurrently=True, | ||
| postgresql_using="gin", | ||
| postgresql_ops={column: "gin_trgm_ops"}, |
There was a problem hiding this comment.
P2: When q is one or two characters, these indexes do not make the organization search index-backed because pg_trgm cannot extract a trigram from the pattern. Enforce a minimum search length before running this query or add a separate strategy for short searches.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/migrations/versions/2026-09-09-2145_add_pg_trgm_gin_indexes_for_backoffice_.py, line 55:
<comment>When `q` is one or two characters, these indexes do not make the organization search index-backed because `pg_trgm` cannot extract a trigram from the pattern. Enforce a minimum search length before running this query or add a separate strategy for short searches.</comment>
<file context>
@@ -0,0 +1,68 @@
+ [column],
+ postgresql_concurrently=True,
+ postgresql_using="gin",
+ postgresql_ops={column: "gin_trgm_ops"},
+ )
+
</file context>
| postgresql_ops={"name": "gin_trgm_ops"}, | ||
| ), | ||
| Index( | ||
| "ix_organizations_slug_trgm", |
There was a problem hiding this comment.
P2: Slug searches still cannot use this trigram index because Organization.slug is CITEXT, so ilike() resolves to citext's operator rather than the text operator in gin_trgm_ops. Create the trigram index on slug::text and cast the slug expression to text in the backoffice predicate (updating both the migration and ORM declaration).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/polar/models/organization.py, line 542:
<comment>Slug searches still cannot use this trigram index because `Organization.slug` is `CITEXT`, so `ilike()` resolves to citext's operator rather than the `text` operator in `gin_trgm_ops`. Create the trigram index on `slug::text` and cast the slug expression to `text` in the backoffice predicate (updating both the migration and ORM declaration).</comment>
<file context>
@@ -530,6 +530,26 @@ class Organization(RateLimitGroupMixin, RecordModel):
+ postgresql_ops={"name": "gin_trgm_ops"},
+ ),
+ Index(
+ "ix_organizations_slug_trgm",
+ "slug",
+ postgresql_using="gin",
</file context>
There was a problem hiding this comment.
1 existing issue remains and no new issues found across 5 files (changes from recent commits).
Confidence score: 4/5
server/polar/models/organization.pyadds an index on(slug::text), but existing backofficeOrganization.slug.ilike(...)predicates may not use it, reducing search performance; update those predicates to castOrganization.slugtoTextconsistently.
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Re-trigger cubic
There was a problem hiding this comment.
0 issues found across 2 files (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.
Re-trigger cubic
There was a problem hiding this comment.
0 issues found across 9 files (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.
Re-trigger cubic
The backoffice organization search filters name/slug/email with a leading-wildcard `ILIKE '%q%'`, which no btree index can serve, so it seq-scans the now-large organizations table. One recent search hit the 30s statement timeout and returned a 500. Add pg_trgm GIN trigram indexes on name, slug and email so those `ILIKE` predicates become index-backed. The indexes are built CONCURRENTLY so the deploy is non-blocking, and pg_trgm is registered as a tracked alembic_utils entity alongside citext/uuid-ossp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two follow-ups on the pg_trgm indexes: `slug` is CITEXT, so `slug ILIKE '%q%'` resolves to citext's `~~*` operator instead of the `text` one in `gin_trgm_ops` — the index was never eligible and the slug branch of the search kept seq-scanning. Index the `slug::text` expression and cast the column the same way in the search predicate. Verified with EXPLAIN: the three-way OR now bitmap-ORs all three trigram indexes, where the uncast predicate seq-scans even with `enable_seqscan = off`. pg_trgm also can't extract a trigram from a one- or two-character pattern, so short searches fell back to the same full scan — and the search box fires on every keystroke. Require three characters and show a "Keep Typing" hint instead of running the query. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGuVPEaf3zemquZPN39rFx
`lazy_counts_url` forwards the raw query params, so a whitespace-only `q` reached `status_counts` unnormalized: the list treated it as no search and excluded deleted organizations, while the tab counts saw a truthy `q` and included them. Share the normalization between both endpoints so they always derive the same deleted filter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGuVPEaf3zemquZPN39rFx
332ffd9 to
f36677b
Compare
The CITEXT slug trap wasn't limited to the organization list: every backoffice search that matches an organization by name or slug had the same uncast `slug ILIKE`, and because the OR's slug branch has no index path, the planner couldn't use the name index either. Move the predicate into `backoffice/search.py` so the cast lives in one place, and use it from the organization, order, customer, product, subscription and webhook searches. The order search gains an index-backed plan (its organization filter is its own AND-ed clause): EXPLAIN ANALYZE over 60k organizations goes from a seq scan discarding 59,999 rows to a BitmapOr over both trigram indexes. The other four OR the organization predicate together with columns of the driving table, so Postgres has to evaluate them as a join filter and no index applies until that shape changes — the cast is correctness-neutral there, and keeps them from re-introducing the trap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGuVPEaf3zemquZPN39rFx
f36677b to
098a875
Compare
There was a problem hiding this comment.
0 issues found across 8 files (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.
Re-trigger cubic
Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Confidence score: 3/5
server/migrations/versions/2026-09-09-2145_add_pg_trgm_gin_indexes_for_backoffice_.pychanges the migration parent so environments that already applied1b299ae956f3under its old lineage may skip382c4661fdb2, leaving the intended migration unapplied despite being stamped at the current head; preserve a compatible Alembic migration path for those environments.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="server/migrations/versions/2026-09-09-2145_add_pg_trgm_gin_indexes_for_backoffice_.py">
<violation number="1" location="server/migrations/versions/2026-09-09-2145_add_pg_trgm_gin_indexes_for_backoffice_.py:16">
P2: When an environment has already applied `1b299ae956f3` with its old parent, this reparenting makes `382c4661fdb2` an ancestor that Alembic will skip because the database is already stamped at the current head. Preserve the old parent and add a merge/bridge revision, or explicitly reconcile those databases, so the member index migration still runs.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = "1b299ae956f3" | ||
| down_revision = "382c4661fdb2" |
There was a problem hiding this comment.
P2: When an environment has already applied 1b299ae956f3 with its old parent, this reparenting makes 382c4661fdb2 an ancestor that Alembic will skip because the database is already stamped at the current head. Preserve the old parent and add a merge/bridge revision, or explicitly reconcile those databases, so the member index migration still runs.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/migrations/versions/2026-09-09-2145_add_pg_trgm_gin_indexes_for_backoffice_.py, line 16:
<comment>When an environment has already applied `1b299ae956f3` with its old parent, this reparenting makes `382c4661fdb2` an ancestor that Alembic will skip because the database is already stamped at the current head. Preserve the old parent and add a merge/bridge revision, or explicitly reconcile those databases, so the member index migration still runs.</comment>
<file context>
@@ -13,7 +13,7 @@
# revision identifiers, used by Alembic.
revision = "1b299ae956f3"
-down_revision = "a0bc64d272f1"
+down_revision = "382c4661fdb2"
branch_labels: tuple[str] | None = None
depends_on: tuple[str] | None = None
</file context>
Problem
The backoffice organization search (
server/polar/backoffice/organizations_v2/endpoints.py) filtersname,slugandemailwith a leading-wildcardILIKE '%q%':The leading
%means no btree index can serve the predicate, so Postgres does a full sequential scan of the now-largeorganizationstable. One recent search hit the 30s statement timeout and returned a 500.Fix
Add
pg_trgmGIN trigram indexes onorganizations.name,organizations.slugandorganizations.email. Trigram GIN indexes makeILIKE '%term%'index-backed, turning the seq-scan into an index scan.CREATE EXTENSION IF NOT EXISTS pg_trgm) and creates the three indexes withgin_trgm_ops.CREATE INDEX CONCURRENTLY(viaop.get_context().autocommit_block(), the same pattern the repo already uses), so the deploy is non-blocking — no table lock in production. A previously-interrupted concurrent build is cleaned up first (DROP INDEX ... IF EXISTS CONCURRENTLY) so re-runs are safe.downgradedrops the three indexes (alsoCONCURRENTLY/IF EXISTS) but intentionally leaves thepg_trgmextension in place, since other objects may rely on it.Organizationmodel's__table_args__andpg_trgmis registered as a trackedalembic_utilsPGExtension(alongsidecitext/uuid-ossp), matching how the codebase tracks GIN indexes and extensions.uv run alembic checkreports no drift.The endpoint query is unchanged — the
ILIKEstays as-is; the indexes just make it fast.Ship safety
CREATE INDEX CONCURRENTLYcannot run inside a transaction, and Alembic wraps migrations in one by default — handled here withautocommit_block. Per the repo's ship-safety conventions this migration should ship on its own so the concurrent index build runs outside a transaction.Verification
uv run alembic upgrade headapplies cleanly against the local DB; the three GIN indexes and thepg_trgmextension are created.downgraderemoves all three indexes; re-upgraderestores them (round-trip tested).uv run alembic check→ no new operations.uv run task lintandmypyon the touched files pass.🤖 Generated with Claude Code