Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""add pg_trgm gin indexes for backoffice organization search

Revision ID: 1b299ae956f3
Revises: 382c4661fdb2
Create Date: 2026-09-09 21:45:27.915455

"""

import sqlalchemy as sa
from alembic import op

# Polar Custom Imports

# revision identifiers, used by Alembic.
revision = "1b299ae956f3"
down_revision = "382c4661fdb2"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

branch_labels: tuple[str] | None = None
depends_on: tuple[str] | None = None

NAME_INDEX = "ix_organizations_name_trgm"
SLUG_INDEX = "ix_organizations_slug_trgm"
EMAIL_INDEX = "ix_organizations_email_trgm"


def upgrade() -> None:
# The backoffice organization search runs a leading-wildcard
# `ILIKE '%q%'` across name/slug/email, which no btree index can serve, so
# it seq-scans the now-large organizations table (recently hit the 30s
# statement timeout). pg_trgm GIN indexes make those `ILIKE` predicates
# index-backed. Built CONCURRENTLY so the deploy never locks the table.
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")

# CREATE INDEX CONCURRENTLY cannot run inside a transaction; autocommit_block
# commits the pending extension change and runs each build in autocommit.
with op.get_context().autocommit_block():
for index_name, expression in (
(NAME_INDEX, "name"),
# `slug` is CITEXT: `slug ILIKE ...` resolves to citext's operator,
# which `gin_trgm_ops` (a `text` opclass) doesn't serve, so index
# the `slug::text` expression the search casts to.
(SLUG_INDEX, "(slug::text)"),
(EMAIL_INDEX, "email"),
):
# A previously-interrupted concurrent build leaves an INVALID index of
# the same name; drop it first so a re-run doesn't fail on "already
# exists".
op.drop_index(
index_name,
table_name="organizations",
if_exists=True,
postgresql_concurrently=True,
)
op.create_index(
index_name,
"organizations",
[sa.text(f"{expression} gin_trgm_ops")],
postgresql_concurrently=True,
postgresql_using="gin",
)


def downgrade() -> None:
# Leave the pg_trgm extension in place; other indexes may rely on it.
with op.get_context().autocommit_block():
for index_name in (NAME_INDEX, SLUG_INDEX, EMAIL_INDEX):
op.drop_index(
index_name,
table_name="organizations",
if_exists=True,
postgresql_concurrently=True,
)
4 changes: 2 additions & 2 deletions server/polar/backoffice/customers/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from ..formatters import currency
from ..layout import layout
from ..responses import HXRedirectResponse
from ..search import organization_ilike
from ..toast import add_toast
from .components import customers_datatable, email_verified_badge

Expand Down Expand Up @@ -181,8 +182,7 @@ async def list(
or_(
Customer.search_vector.op("@@")(ts_query_simple),
Customer.external_id.ilike(ilike_term),
Organization.slug.ilike(ilike_term),
Organization.name.ilike(ilike_term),
organization_ilike(ilike_term),
)
)

Expand Down
8 changes: 2 additions & 6 deletions server/polar/backoffice/orders/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from ..components import button, datatable, description_list, input, modal
from ..layout import layout
from ..responses import HXRedirectResponse
from ..search import organization_ilike
from ..toast import add_toast
from .components import order_status_badge, orders_datatable, payments_datatable
from .forms import RefundForm
Expand Down Expand Up @@ -150,12 +151,7 @@ async def list(
parsed_org_uuid = uuid.UUID(organization)
statement = statement.where(Organization.id == parsed_org_uuid)
except ValueError:
statement = statement.where(
or_(
Organization.slug.ilike(f"%{organization}%"),
Organization.name.ilike(f"%{organization}%"),
)
)
statement = statement.where(organization_ilike(f"%{organization}%"))

if status is not None:
statement = statement.where(Order.status == status)
Expand Down
36 changes: 28 additions & 8 deletions server/polar/backoffice/organizations_v2/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from fastapi.datastructures import FormData
from pydantic import UUID4, BaseModel, Field, ValidationError, field_validator
from pydantic_core import PydanticCustomError, SchemaSerializer, core_schema
from sqlalchemy import Select, and_, func, or_, select
from sqlalchemy import Select, and_, false, func, or_, select
from sqlalchemy.orm import contains_eager, joinedload
from sse_starlette.sse import EventSourceResponse
from tagflow import tag, text
Expand Down Expand Up @@ -118,6 +118,7 @@
from ..dependencies import get_admin
from ..layout import layout
from ..responses import HXRedirectResponse
from ..search import organization_ilike
from ..support_cases.queries import cases_statement, open_case_organization_ids
from ..support_cases.urls import append_return_to, case_detail_url
from ..toast import add_toast
Expand All @@ -140,6 +141,7 @@
from .priority import Signals
from .views.detail_view import OrganizationDetailView
from .views.list_view import (
MIN_SEARCH_LENGTH,
DeletedFilter,
OrganizationListView,
apply_deleted_filter,
Expand Down Expand Up @@ -513,6 +515,10 @@ def _parse_status_filter(status: str | None) -> OrganizationStatus | None:
return _STATUS_FILTERS.get(status) if status else None


def _normalize_search(q: str | None) -> str | None:
return (q.strip() or None) if q else None


def _apply_sql_sort(stmt: Select[Any], sort: str, direction: str) -> Select[Any]:
is_desc = direction == "desc"
if sort == "name":
Expand Down Expand Up @@ -592,6 +598,7 @@ async def list_organizations(
list_view = OrganizationListView(session)

# Convert empty strings to None and parse numbers
q = _normalize_search(q)
country = country if country else None
risk_level = risk_level if risk_level else None
has_appeal = has_appeal if has_appeal else None
Expand Down Expand Up @@ -635,18 +642,25 @@ async def list_organizations(
)
)

search_too_short = False
if q:
try:
stmt = stmt.where(Organization.id == uuid.UUID(q))
except ValueError:
search_term = f"%{q}%"
stmt = stmt.where(
or_(
Organization.name.ilike(search_term),
Organization.slug.ilike(search_term),
Organization.email.ilike(search_term),
if len(q) < MIN_SEARCH_LENGTH:
# pg_trgm can't extract a trigram from a shorter pattern, so
# the search would fall back to a seq scan of the whole table
# (the search box fires on every keystroke).
search_too_short = True
stmt = stmt.where(false())
else:
search_term = f"%{q}%"
stmt = stmt.where(
or_(
organization_ilike(search_term),
Organization.email.ilike(search_term),
)
)
)

# Country filter
if country:
Expand Down Expand Up @@ -772,6 +786,7 @@ async def list_organizations(
open_case_org_ids=open_case_org_ids,
awaiting_reply_org_ids=awaiting_reply_org_ids,
selected_open_cases=selected_open_cases,
search_too_short=search_too_short,
):
pass
else:
Expand Down Expand Up @@ -811,6 +826,7 @@ async def list_organizations(
awaiting_reply_org_ids=awaiting_reply_org_ids,
selected_open_cases=selected_open_cases,
open_cases_count=open_cases_count,
search_too_short=search_too_short,
lazy_counts_url=str(
request.url_for("organizations:status_counts").include_query_params(
**{k: v for k, v in request.query_params.items() if v}
Expand All @@ -829,6 +845,10 @@ async def status_counts(
deleted: DeletedFilter | None = Query(None),
) -> None:
list_view = OrganizationListView(session)
# Same normalization as the list: `lazy_counts_url` forwards the raw query
# params, so a whitespace-only `q` must not flip the deleted filter here
# while the list treats it as no search at all.
q = _normalize_search(q)
deleted_filter: DeletedFilter = deleted or ("include" if q else "exclude")
open_cases_count = (
await session.scalar(
Expand Down
29 changes: 23 additions & 6 deletions server/polar/backoffice/organizations_v2/views/list_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@

FIRST_REVIEW_THRESHOLD_LABEL = formatters.currency(FIRST_REVIEW_THRESHOLD_CENTS, "usd")

# Shorter searches can't use the trigram indexes backing the name/slug/email
# `ILIKE` filters: pg_trgm extracts no trigram from a one- or two-character
# pattern, leaving a seq scan of the whole organizations table.
MIN_SEARCH_LENGTH = 3

DeletedFilter = Literal["exclude", "include", "only"]


Expand Down Expand Up @@ -478,6 +483,7 @@ def render(
awaiting_reply_org_ids: set[uuid.UUID] | None = None,
selected_open_cases: bool = False,
open_cases_count: int = 0,
search_too_short: bool = False,
lazy_counts_url: str | None = None,
) -> Generator[None]:
"""Render the complete list view."""
Expand Down Expand Up @@ -734,6 +740,7 @@ def render(
open_case_org_ids,
awaiting_reply_org_ids,
selected_open_cases,
search_too_short,
)

yield
Expand All @@ -751,6 +758,7 @@ def _render_org_list(
open_case_org_ids: set[uuid.UUID] | None = None,
awaiting_reply_org_ids: set[uuid.UUID] | None = None,
selected_open_cases: bool = False,
search_too_short: bool = False,
) -> None:
"""Render the ``#org-list`` block — table with Review-only columns.

Expand All @@ -771,11 +779,18 @@ def _render_org_list(

with tag.div(id="org-list", classes="overflow-x-auto"):
if not organizations:
with empty_state(
"No Organizations Found",
"No organizations match your current filters.",
):
pass
if search_too_short:
with empty_state(
"Keep Typing",
f"Enter at least {MIN_SEARCH_LENGTH} characters to search.",
):
pass
else:
with empty_state(
"No Organizations Found",
"No organizations match your current filters.",
):
pass
else:
with tag.table(classes="table table-zebra w-full"):
with tag.thead():
Expand Down Expand Up @@ -899,6 +914,7 @@ def render_table_only(
open_case_org_ids: set[uuid.UUID] | None = None,
awaiting_reply_org_ids: set[uuid.UUID] | None = None,
selected_open_cases: bool = False,
search_too_short: bool = False,
) -> Generator[None]:
"""Render only the organization table (for HTMX updates)."""

Expand All @@ -914,9 +930,10 @@ def render_table_only(
open_case_org_ids,
awaiting_reply_org_ids,
selected_open_cases,
search_too_short,
)

yield


__all__ = ["OrganizationListView"]
__all__ = ["MIN_SEARCH_LENGTH", "OrganizationListView"]
4 changes: 2 additions & 2 deletions server/polar/backoffice/products/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from .. import formatters
from ..components import button, datatable, description_list, input
from ..layout import layout
from ..search import organization_ilike

router = BackofficeRouter()

Expand Down Expand Up @@ -110,8 +111,7 @@ async def list(
statement = statement.where(
or_(
Product.search_vector.op("@@")(ts_query_english),
Organization.slug.ilike(ilike_term),
Organization.name.ilike(ilike_term),
organization_ilike(ilike_term),
)
)

Expand Down
17 changes: 17 additions & 0 deletions server/polar/backoffice/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from sqlalchemy import ColumnElement, Text, or_

from polar.models import Organization


def organization_ilike(term: str) -> ColumnElement[bool]:
"""Match an organization by name or slug, e.g. ``organization_ilike("%acme%")``.

``Organization.slug`` is ``CITEXT``, so an uncast ``ILIKE`` binds citext's
own operator and can't use ``ix_organizations_slug_trgm`` — which also
stops the planner from using the name index, since neither branch of the
``OR`` would be index-backed. Cast to ``text`` so both apply.
"""
return or_(
Organization.name.ilike(term),
Organization.slug.cast(Text).ilike(term),
)
4 changes: 2 additions & 2 deletions server/polar/backoffice/subscriptions/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from ..layout import layout
from ..orders.components import orders_datatable
from ..responses import HXRedirectResponse
from ..search import organization_ilike
from ..toast import add_toast
from .forms import CancelForm, UpdateBillingPeriodEndForm, build_update_status_form

Expand Down Expand Up @@ -147,8 +148,7 @@ async def list(
statement = statement.where(
or_(
Customer.search_vector.op("@@")(ts_query_simple),
Organization.slug.ilike(ilike_term),
Organization.name.ilike(ilike_term),
organization_ilike(ilike_term),
)
)

Expand Down
4 changes: 2 additions & 2 deletions server/polar/backoffice/webhooks/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from ..components import button, confirmation_dialog, datatable, description_list, input
from ..layout import layout
from ..search import organization_ilike
from ..toast import add_toast

router = BackofficeRouter()
Expand Down Expand Up @@ -49,8 +50,7 @@ async def list(
statement = statement.where(
or_(
WebhookEndpoint.url.ilike(f"%{query}%"),
Organization.slug.ilike(f"%{query}%"),
Organization.name.ilike(f"%{query}%"),
organization_ilike(f"%{query}%"),
)
)

Expand Down
3 changes: 2 additions & 1 deletion server/polar/kit/db/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ class RateLimitGroupMixin:

uuid_ossp = PGExtension(schema="public", signature="uuid-ossp")
citext = PGExtension(schema="public", signature="citext")
pg_trgm = PGExtension(schema="public", signature="pg_trgm")
register_entities(
(uuid_ossp, citext),
(uuid_ossp, citext, pg_trgm),
entity_types=(PGExtension, PGFunction, PGTrigger),
)
Loading
Loading